diff options
Diffstat (limited to 'tests/phpunit/includes')
259 files changed, 48167 insertions, 0 deletions
diff --git a/tests/phpunit/includes/ArrayUtilsTest.php b/tests/phpunit/includes/ArrayUtilsTest.php new file mode 100644 index 00000000..7bdb1ca4 --- /dev/null +++ b/tests/phpunit/includes/ArrayUtilsTest.php @@ -0,0 +1,311 @@ +<?php +/** + * Test class for ArrayUtils class + * + * @group Database + */ + +class ArrayUtilsTest extends MediaWikiTestCase { + private $search; + + /** + * @covers ArrayUtils::findLowerBound + * @dataProvider provideFindLowerBound + */ + function testFindLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target, $expected + ) { + $this->assertSame( + ArrayUtils::findLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target + ), $expected + ); + } + + function provideFindLowerBound() { + $self = $this; + $indexValueCallback = function ( $size ) use ( $self ) { + return function ( $val ) use ( $self, $size ) { + $self->assertTrue( $val >= 0 ); + $self->assertTrue( $val < $size ); + return $val; + }; + }; + $comparisonCallback = function ( $a, $b ) { + return $a - $b; + }; + + return array( + array( + $indexValueCallback( 0 ), + 0, + $comparisonCallback, + 1, + false, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + -1, + false, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 0, + 0, + ), + array( + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 1, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + -1, + false, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0.5, + 0, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1, + 1, + ), + array( + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1.5, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1.5, + 1, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 2, + 2, + ), + array( + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 3, + 2, + ), + ); + } + + /** + * @covers ArrayUtils::arrayDiffAssocRecursive + * @dataProvider provideArrayDiffAssocRecursive + */ + function testArrayDiffAssocRecursive( $expected ) { + $args = func_get_args(); + array_shift( $args ); + $this->assertEquals( call_user_func_array( + 'ArrayUtils::arrayDiffAssocRecursive', $args + ), $expected ); + } + + function provideArrayDiffAssocRecursive() { + return array( + array( + array(), + array(), + array(), + ), + array( + array(), + array(), + array(), + array(), + ), + array( + array( 1 ), + array( 1 ), + array(), + ), + array( + array( 1 ), + array( 1 ), + array(), + array(), + ), + array( + array(), + array(), + array( 1 ), + ), + array( + array(), + array(), + array( 1 ), + array( 2 ), + ), + array( + array( '' => 1 ), + array( '' => 1 ), + array(), + ), + array( + array(), + array(), + array( '' => 1 ), + ), + array( + array( 1 ), + array( 1 ), + array( 2 ), + ), + array( + array(), + array( 1 ), + array( 2 ), + array( 1 ), + ), + array( + array(), + array( 1 ), + array( 1, 2 ), + ), + array( + array( 1 => 1 ), + array( 1 => 1 ), + array( 1 ), + ), + array( + array(), + array( 1 => 1 ), + array( 1 ), + array( 1 => 1), + ), + array( + array(), + array( 1 => 1 ), + array( 1, 1, 1 ), + ), + array( + array(), + array( array() ), + array(), + ), + array( + array(), + array( array( array() ) ), + array(), + ), + array( + array( 1, array( 1 ) ), + array( 1, array( 1 ) ), + array(), + ), + array( + array( 1 ), + array( 1, array( 1 ) ), + array( 2, array( 1 ) ), + ), + array( + array(), + array( 1, array( 1 ) ), + array( 2, array( 1 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 ), + array( 1, array() ), + array( 2 ), + ), + array( + array(), + array( 1, array() ), + array( 2 ), + array( 1 ), + ), + array( + array( 1, array( 1 => 2 ) ), + array( 1, array( 1, 2 ) ), + array( 2, array( 1 ) ), + ), + array( + array( 1 ), + array( 1, array( 1, 2 ) ), + array( 2, array( 1 ) ), + array( 2, array( 1 => 2 ) ), + ), + array( + array( 1 => array( 1, 2 ) ), + array( 1, array( 1, 2 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 => array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( 2 ) ), + ), + array( + array( 1 => array( array( 2 ), 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3 ) ) ), + ), + array( + array( 1 => array( 1 => 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3, 0 => 2 ) ) ), + ), + array( + array( 1 => array( 1 => 2 ) ), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1, array( array( 1 => 3 ) ) ), + array( 1 => array( array( 2 ) ) ), + ), + array( + array(), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1 => array( 1 => 2, 0 => array( 1 => 3, 0 => 2 ) ), 0 => 1 ), + ), + array( + array(), + array( 1, array( array( 2, 3 ), 2 ) ), + array( 1 => array( 1 => 2 ) ), + array( 1 => array( array( 1 => 3 ) ) ), + array( 1 => array( array( 2 ) ) ), + array( 1 ), + ), + ); + } +} diff --git a/tests/phpunit/includes/ArticleTablesTest.php b/tests/phpunit/includes/ArticleTablesTest.php new file mode 100644 index 00000000..9f2b7a05 --- /dev/null +++ b/tests/phpunit/includes/ArticleTablesTest.php @@ -0,0 +1,53 @@ +<?php + +/** + * @group Database + */ +class ArticleTablesTest extends MediaWikiLangTestCase { + /** + * Make sure that bug 14404 doesn't strike again. We don't want + * templatelinks based on the user language when {{int:}} is used, only the + * content language. + * + * @covers Title::getTemplateLinksFrom + * @covers Title::getLinksFrom + */ + public function testTemplatelinksUsesContentLanguage() { + $title = Title::newFromText( 'Bug 14404' ); + $page = WikiPage::factory( $title ); + $user = new User(); + $user->mRights = array( 'createpage', 'edit', 'purge' ); + $this->setMwGlobals( 'wgLanguageCode', 'es' ); + $this->setMwGlobals( 'wgContLang', Language::factory( 'es' ) ); + $this->setMwGlobals( 'wgLang', Language::factory( 'fr' ) ); + + $page->doEditContent( + new WikitextContent( '{{:{{int:history}}}}' ), + 'Test code for bug 14404', + 0, + false, + $user + ); + $templates1 = $title->getTemplateLinksFrom(); + + $this->setMwGlobals( 'wgLang', Language::factory( 'de' ) ); + $page = WikiPage::factory( $title ); // In order to force the re-rendering of the same wikitext + + // We need an edit, a purge is not enough to regenerate the tables + $page->doEditContent( + new WikitextContent( '{{:{{int:history}}}}' ), + 'Test code for bug 14404', + EDIT_UPDATE, + false, + $user + ); + $templates2 = $title->getTemplateLinksFrom(); + + /** + * @var Title[] $templates1 + * @var Title[] $templates2 + */ + $this->assertEquals( $templates1, $templates2 ); + $this->assertEquals( $templates1[0]->getFullText(), 'Historial' ); + } +} diff --git a/tests/phpunit/includes/ArticleTest.php b/tests/phpunit/includes/ArticleTest.php new file mode 100644 index 00000000..ae069eaf --- /dev/null +++ b/tests/phpunit/includes/ArticleTest.php @@ -0,0 +1,95 @@ +<?php + +class ArticleTest extends MediaWikiTestCase { + + /** + * @var Title + */ + private $title; + /** + * @var Article + */ + private $article; + + /** creates a title object and its article object */ + protected function setUp() { + parent::setUp(); + $this->title = Title::makeTitle( NS_MAIN, 'SomePage' ); + $this->article = new Article( $this->title ); + } + + /** cleanup title object and its article object */ + protected function tearDown() { + parent::tearDown(); + $this->title = null; + $this->article = null; + } + + /** + * @covers Article::__get + */ + public function testImplementsGetMagic() { + $this->assertEquals( false, $this->article->mLatest, "Article __get magic" ); + } + + /** + * @depends testImplementsGetMagic + * @covers Article::__set + */ + public function testImplementsSetMagic() { + $this->article->mLatest = 2; + $this->assertEquals( 2, $this->article->mLatest, "Article __set magic" ); + } + + /** + * @depends testImplementsSetMagic + * @covers Article::__call + */ + public function testImplementsCallMagic() { + $this->article->mLatest = 33; + $this->article->mDataLoaded = true; + $this->assertEquals( 33, $this->article->getLatest(), "Article __call magic" ); + } + + /** + * @covers Article::__get + * @covers Article::__set + */ + public function testGetOrSetOnNewProperty() { + $this->article->ext_someNewProperty = 12; + $this->assertEquals( 12, $this->article->ext_someNewProperty, + "Article get/set magic on new field" ); + + $this->article->ext_someNewProperty = -8; + $this->assertEquals( -8, $this->article->ext_someNewProperty, + "Article get/set magic on update to new field" ); + } + + /** + * Checks for the existence of the backwards compatibility static functions + * (forwarders to WikiPage class) + * + * @covers Article::selectFields + * @covers Article::onArticleCreate + * @covers Article::onArticleDelete + * @covers Article::onArticleEdit + * @covers Article::getAutosummary + */ + public function testStaticFunctions() { + $this->hideDeprecated( 'Article::selectFields' ); + $this->hideDeprecated( 'Article::getAutosummary' ); + $this->hideDeprecated( 'WikiPage::getAutosummary' ); + $this->hideDeprecated( 'CategoryPage::getAutosummary' ); // Inherited from Article + + $this->assertEquals( WikiPage::selectFields(), Article::selectFields(), + "Article static functions" ); + $this->assertEquals( true, is_callable( "Article::onArticleCreate" ), + "Article static functions" ); + $this->assertEquals( true, is_callable( "Article::onArticleDelete" ), + "Article static functions" ); + $this->assertEquals( true, is_callable( "ImagePage::onArticleEdit" ), + "Article static functions" ); + $this->assertTrue( is_string( CategoryPage::getAutosummary( '', '', 0 ) ), + "Article static functions" ); + } +} diff --git a/tests/phpunit/includes/BlockTest.php b/tests/phpunit/includes/BlockTest.php new file mode 100644 index 00000000..b248d24e --- /dev/null +++ b/tests/phpunit/includes/BlockTest.php @@ -0,0 +1,368 @@ +<?php + +/** + * @group Database + * @group Blocking + */ +class BlockTest extends MediaWikiLangTestCase { + + /** @var Block */ + private $block; + private $madeAt; + + /* variable used to save up the blockID we insert in this test suite */ + private $blockId; + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ) + ) ); + } + + function addDBData() { + + $user = User::newFromName( 'UTBlockee' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTBlockeePassword' ); + + $user->saveSettings(); + } + + // Delete the last round's block if it's still there + $oldBlock = Block::newFromTarget( 'UTBlockee' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + $this->block = new Block( 'UTBlockee', $user->getID(), 0, + 'Parce que', 0, false, time() + 100500 + ); + $this->madeAt = wfTimestamp( TS_MW ); + + $this->block->insert(); + // save up ID for use in assertion. Since ID is an autoincrement, + // its value might change depending on the order the tests are run. + // ApiBlockTest insert its own blocks! + $newBlockId = $this->block->getId(); + if ( $newBlockId ) { + $this->blockId = $newBlockId; + } else { + throw new MWException( "Failed to insert block for BlockTest; old leftover block remaining?" ); + } + + $this->addXffBlocks(); + } + + /** + * debug function : dump the ipblocks table + */ + function dumpBlocks() { + $v = $this->db->select( 'ipblocks', '*' ); + print "Got " . $v->numRows() . " rows. Full dump follow:\n"; + foreach ( $v as $row ) { + print_r( $row ); + } + } + + /** + * @covers Block::newFromTarget + */ + public function testINewFromTargetReturnsCorrectBlock() { + $this->assertTrue( + $this->block->equals( Block::newFromTarget( 'UTBlockee' ) ), + "newFromTarget() returns the same block as the one that was made" + ); + } + + /** + * @covers Block::newFromID + */ + public function testINewFromIDReturnsCorrectBlock() { + $this->assertTrue( + $this->block->equals( Block::newFromID( $this->blockId ) ), + "newFromID() returns the same block as the one that was made" + ); + } + + /** + * per bug 26425 + */ + public function testBug26425BlockTimestampDefaultsToTime() { + // delta to stop one-off errors when things happen to go over a second mark. + $delta = abs( $this->madeAt - $this->block->mTimestamp ); + $this->assertLessThan( + 2, + $delta, + "If no timestamp is specified, the block is recorded as time()" + ); + } + + /** + * CheckUser since being changed to use Block::newFromTarget started failing + * because the new function didn't accept empty strings like Block::load() + * had. Regression bug 29116. + * + * @dataProvider provideBug29116Data + * @covers Block::newFromTarget + */ + public function testBug29116NewFromTargetWithEmptyIp( $vagueTarget ) { + $block = Block::newFromTarget( 'UTBlockee', $vagueTarget ); + $this->assertTrue( + $this->block->equals( $block ), + "newFromTarget() returns the same block as the one that was made when " + . "given empty vagueTarget param " . var_export( $vagueTarget, true ) + ); + } + + public static function provideBug29116Data() { + return array( + array( null ), + array( '' ), + array( false ) + ); + } + + /** + * @covers Block::prevents + */ + public function testBlockedUserCanNotCreateAccount() { + $username = 'BlockedUserToCreateAccountWith'; + $u = User::newFromName( $username ); + $u->setPassword( 'NotRandomPass' ); + $u->addToDatabase(); + unset( $u ); + + // Sanity check + $this->assertNull( + Block::newFromTarget( $username ), + "$username should not be blocked" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertFalse( + $u->isBlockedFromCreateAccount(), + "Our sandbox user should be able to create account before being blocked" + ); + + // Foreign perspective (blockee not on current wiki)... + $block = new Block( + /* $address */ $username, + /* $user */ 14146, + /* $by */ 0, + /* $reason */ 'crosswiki block...', + /* $timestamp */ wfTimestampNow(), + /* $auto */ false, + /* $expiry */ $this->db->getInfinity(), + /* anonOnly */ false, + /* $createAccount */ true, + /* $enableAutoblock */ true, + /* $hideName (ipb_deleted) */ true, + /* $blockEmail */ true, + /* $allowUsertalk */ false, + /* $byName */ 'MetaWikiUser' + ); + $block->insert(); + + // Reload block from DB + $userBlock = Block::newFromTarget( $username ); + $this->assertTrue( + (bool)$block->prevents( 'createaccount' ), + "Block object in DB should prevents 'createaccount'" + ); + + $this->assertInstanceOf( + 'Block', + $userBlock, + "'$username' block block object should be existent" + ); + + // Reload user + $u = User::newFromName( $username ); + $this->assertTrue( + (bool)$u->isBlockedFromCreateAccount(), + "Our sandbox user '$username' should NOT be able to create account" + ); + } + + /** + * @covers Block::insert + */ + public function testCrappyCrossWikiBlocks() { + // Delete the last round's block if it's still there + $oldBlock = Block::newFromTarget( 'UserOnForeignWiki' ); + if ( $oldBlock ) { + // An old block will prevent our new one from saving. + $oldBlock->delete(); + } + + // Foreign perspective (blockee not on current wiki)... + $block = new Block( + /* $address */ 'UserOnForeignWiki', + /* $user */ 14146, + /* $by */ 0, + /* $reason */ 'crosswiki block...', + /* $timestamp */ wfTimestampNow(), + /* $auto */ false, + /* $expiry */ $this->db->getInfinity(), + /* anonOnly */ false, + /* $createAccount */ true, + /* $enableAutoblock */ true, + /* $hideName (ipb_deleted) */ true, + /* $blockEmail */ true, + /* $allowUsertalk */ false, + /* $byName */ 'MetaWikiUser' + ); + + $res = $block->insert( $this->db ); + $this->assertTrue( (bool)$res['id'], 'Block succeeded' ); + + // Local perspective (blockee on current wiki)... + $user = User::newFromName( 'UserOnForeignWiki' ); + $user->addToDatabase(); + // Set user ID to match the test value + $this->db->update( 'user', array( 'user_id' => 14146 ), array( 'user_id' => $user->getId() ) ); + $user = null; // clear + + $block = Block::newFromID( $res['id'] ); + $this->assertEquals( + 'UserOnForeignWiki', + $block->getTarget()->getName(), + 'Correct blockee name' + ); + $this->assertEquals( '14146', $block->getTarget()->getId(), 'Correct blockee id' ); + $this->assertEquals( 'MetaWikiUser', $block->getBlocker(), 'Correct blocker name' ); + $this->assertEquals( 'MetaWikiUser', $block->getByName(), 'Correct blocker name' ); + $this->assertEquals( 0, $block->getBy(), 'Correct blocker id' ); + } + + protected function addXffBlocks() { + static $inited = false; + + if ( $inited ) { + return; + } + + $inited = true; + + $blockList = array( + array( 'target' => '70.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ), + array( 'target' => '2001:4860:4001::/48', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range6 Hardblock', + 'ACDisable' => false, + 'isHardblock' => true, + 'isAutoBlocking' => false, + ), + array( 'target' => '60.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Softblock with AC Disabled', + 'ACDisable' => true, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + array( 'target' => '50.2.0.0/16', + 'type' => Block::TYPE_RANGE, + 'desc' => 'Range Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + array( 'target' => '50.1.1.1', + 'type' => Block::TYPE_IP, + 'desc' => 'Exact Softblock', + 'ACDisable' => false, + 'isHardblock' => false, + 'isAutoBlocking' => false, + ), + ); + + foreach ( $blockList as $insBlock ) { + $target = $insBlock['target']; + + if ( $insBlock['type'] === Block::TYPE_IP ) { + $target = User::newFromName( IP::sanitizeIP( $target ), false )->getName(); + } elseif ( $insBlock['type'] === Block::TYPE_RANGE ) { + $target = IP::sanitizeRange( $target ); + } + + $block = new Block(); + $block->setTarget( $target ); + $block->setBlocker( 'testblocker@global' ); + $block->mReason = $insBlock['desc']; + $block->mExpiry = 'infinity'; + $block->prevents( 'createaccount', $insBlock['ACDisable'] ); + $block->isHardblock( $insBlock['isHardblock'] ); + $block->isAutoblocking( $insBlock['isAutoBlocking'] ); + $block->insert(); + } + } + + public static function providerXff() { + return array( + array( 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '1.2.3.4, 50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 50.1.1.1, 2.3.4.5', + 'count' => 3, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 50.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '1.2.3.4, 70.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Hardblock' + ), + array( 'xff' => '50.2.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 50.1.1.1, 60.2.1.1, 2.3.4.5', + 'count' => 2, + 'result' => 'Exact Softblock' + ), + array( 'xff' => '1.2.3.4, <$A_BUNCH-OF{INVALID}TEXT\>, 60.2.1.1, 2.3.4.5', + 'count' => 1, + 'result' => 'Range Softblock with AC Disabled' + ), + array( 'xff' => '1.2.3.4, 50.2.1.1, 2001:4860:4001:802::1003, 2.3.4.5', + 'count' => 2, + 'result' => 'Range6 Hardblock' + ), + ); + } + + /** + * @dataProvider providerXff + * @covers Block::getBlocksForIPList + * @covers Block::chooseBlock + */ + public function testBlocksOnXff( $xff, $exCount, $exResult ) { + $list = array_map( 'trim', explode( ',', $xff ) ); + $xffblocks = Block::getBlocksForIPList( $list, true ); + $this->assertEquals( $exCount, count( $xffblocks ), 'Number of blocks for ' . $xff ); + $block = Block::chooseBlock( $xffblocks, $list ); + $this->assertEquals( $exResult, $block->mReason, 'Correct block type for XFF header ' . $xff ); + } +} diff --git a/tests/phpunit/includes/CollationTest.php b/tests/phpunit/includes/CollationTest.php new file mode 100644 index 00000000..74b12967 --- /dev/null +++ b/tests/phpunit/includes/CollationTest.php @@ -0,0 +1,117 @@ +<?php + +/** + * Class CollationTest + * @covers Collation + * @covers IcuCollation + * @covers IdentityCollation + * @covers UppercaseCollation + */ +class CollationTest extends MediaWikiLangTestCase { + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'intl' ); + } + + /** + * Test to make sure, that if you + * have "X" and "XY", the binary + * sortkey also has "X" being a + * prefix of "XY". Our collation + * code makes this assumption. + * + * @param string $lang Language code for collator + * @param string $base Base string + * @param string $extended String containing base as a prefix. + * + * @dataProvider prefixDataProvider + */ + public function testIsPrefix( $lang, $base, $extended ) { + $cp = Collator::create( $lang ); + $cp->setStrength( Collator::PRIMARY ); + $baseBin = $cp->getSortKey( $base ); + // Remove sortkey terminator + $baseBin = rtrim( $baseBin, "\0" ); + $extendedBin = $cp->getSortKey( $extended ); + $this->assertStringStartsWith( $baseBin, $extendedBin, "$base is not a prefix of $extended" ); + } + + public static function prefixDataProvider() { + return array( + array( 'en', 'A', 'AA' ), + array( 'en', 'A', 'AAA' ), + array( 'en', 'Д', 'ДЂ' ), + array( 'en', 'Д', 'ДA' ), + // 'Ʒ' should expand to 'Z ' (note space). + array( 'fi', 'Z', 'Ʒ' ), + // 'Þ' should expand to 'th' + array( 'sv', 't', 'Þ' ), + // Javanese is a limited use alphabet, so should have 3 bytes + // per character, so do some tests with it. + array( 'en', 'ꦲ', 'ꦲꦤ' ), + array( 'en', 'ꦲ', 'ꦲД' ), + array( 'en', 'A', 'Aꦲ' ), + ); + } + + /** + * Opposite of testIsPrefix + * + * @dataProvider notPrefixDataProvider + */ + public function testNotIsPrefix( $lang, $base, $extended ) { + $cp = Collator::create( $lang ); + $cp->setStrength( Collator::PRIMARY ); + $baseBin = $cp->getSortKey( $base ); + // Remove sortkey terminator + $baseBin = rtrim( $baseBin, "\0" ); + $extendedBin = $cp->getSortKey( $extended ); + $this->assertStringStartsNotWith( $baseBin, $extendedBin, "$base is a prefix of $extended" ); + } + + public static function notPrefixDataProvider() { + return array( + array( 'en', 'A', 'B' ), + array( 'en', 'AC', 'ABC' ), + array( 'en', 'Z', 'Ʒ' ), + array( 'en', 'A', 'ꦲ' ), + ); + } + + /** + * Test correct first letter is fetched. + * + * @param string $collation Collation name (aka uca-en) + * @param string $string String to get first letter of + * @param string $firstLetter Expected first letter. + * + * @dataProvider firstLetterProvider + */ + public function testGetFirstLetter( $collation, $string, $firstLetter ) { + $col = Collation::factory( $collation ); + $this->assertEquals( $firstLetter, $col->getFirstLetter( $string ) ); + } + + function firstLetterProvider() { + return array( + array( 'uppercase', 'Abc', 'A' ), + array( 'uppercase', 'abc', 'A' ), + array( 'identity', 'abc', 'a' ), + array( 'uca-en', 'abc', 'A' ), + array( 'uca-en', ' ', ' ' ), + array( 'uca-en', 'Êveryone', 'E' ), + array( 'uca-vi', 'Êveryone', 'Ê' ), + // Make sure thorn is not a first letter. + array( 'uca-sv', 'The', 'T' ), + array( 'uca-sv', 'Å', 'Å' ), + array( 'uca-hu', 'dzsdo', 'Dzs' ), + array( 'uca-hu', 'dzdso', 'Dz' ), + array( 'uca-hu', 'CSD', 'Cs' ), + array( 'uca-root', 'CSD', 'C' ), + array( 'uca-fi', 'Ǥ', 'G' ), + array( 'uca-fi', 'Ŧ', 'T' ), + array( 'uca-fi', 'Ʒ', 'Z' ), + array( 'uca-fi', 'Ŋ', 'N' ), + ); + } +} diff --git a/tests/phpunit/includes/DiffHistoryBlobTest.php b/tests/phpunit/includes/DiffHistoryBlobTest.php new file mode 100644 index 00000000..e28a92cf --- /dev/null +++ b/tests/phpunit/includes/DiffHistoryBlobTest.php @@ -0,0 +1,40 @@ +<?php + +class DiffHistoryBlobTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->checkPHPExtension( 'hash' ); + $this->checkPHPExtension( 'xdiff' ); + + if ( !function_exists( 'xdiff_string_rabdiff' ) ) { + $this->markTestSkipped( 'The version of xdiff extension is lower than 1.5.0' ); + + return; + } + } + + /** + * Test for DiffHistoryBlob::xdiffAdler32() + * @dataProvider provideXdiffAdler32 + * @covers DiffHistoryBlob::xdiffAdler32 + */ + public function testXdiffAdler32( $input ) { + $xdiffHash = substr( xdiff_string_rabdiff( $input, '' ), 0, 4 ); + $dhb = new DiffHistoryBlob; + $myHash = $dhb->xdiffAdler32( $input ); + $this->assertSame( bin2hex( $xdiffHash ), bin2hex( $myHash ), + "Hash of " . addcslashes( $input, "\0..\37!@\@\177..\377" ) ); + } + + public static function provideXdiffAdler32() { + return array( + array( '', 'Empty string' ), + array( "\0", 'Null' ), + array( "\0\0\0", "Several nulls" ), + array( "Hello", "An ASCII string" ), + array( str_repeat( "x", 6000 ), "A string larger than xdiff's NMAX (5552)" ) + ); + } +} diff --git a/tests/phpunit/includes/EditPageTest.php b/tests/phpunit/includes/EditPageTest.php new file mode 100644 index 00000000..702fce4c --- /dev/null +++ b/tests/phpunit/includes/EditPageTest.php @@ -0,0 +1,499 @@ +<?php + +/** + * @group Editing + * + * @group Database + * ^--- tell jenkins this test needs the database + * + * @group medium + * ^--- tell phpunit that these test cases may take longer than 2 seconds. + */ +class EditPageTest extends MediaWikiLangTestCase { + + /** + * @dataProvider provideExtractSectionTitle + * @covers EditPage::extractSectionTitle + */ + public function testExtractSectionTitle( $section, $title ) { + $extracted = EditPage::extractSectionTitle( $section ); + $this->assertEquals( $title, $extracted ); + } + + public static function provideExtractSectionTitle() { + return array( + array( + "== Test ==\n\nJust a test section.", + "Test" + ), + array( + "An initial section, no header.", + false + ), + array( + "An initial section with a fake heder (bug 32617)\n\n== Test == ??\nwtf", + false + ), + array( + "== Section ==\nfollowed by a fake == Non-section == ??\nnoooo", + "Section" + ), + array( + "== Section== \t\r\n followed by whitespace (bug 35051)", + 'Section', + ), + ); + } + + 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(); + } + + /** + * User input text is passed to rtrim() by edit page. This is a simple + * wrapper around assertEquals() which calls rrtrim() to normalize the + * expected and actual texts. + * @param string $expected + * @param string $actual + * @param string $msg + */ + protected function assertEditedTextEquals( $expected, $actual, $msg = '' ) { + return $this->assertEquals( rtrim( $expected ), rtrim( $actual ), $msg ); + } + + /** + * Performs an edit and checks the result. + * + * @param string|Title $title The title of the page to edit + * @param string|null $baseText Some text to create the page with before attempting the edit. + * @param User|string|null $user The user to perform the edit as. + * @param array $edit An array of request parameters used to define the edit to perform. + * Some well known fields are: + * * wpTextbox1: the text to submit + * * wpSummary: the edit summary + * * wpEditToken: the edit token (will be inserted if not provided) + * * wpEdittime: timestamp of the edit's base revision (will be inserted + * if not provided) + * * wpStarttime: timestamp when the edit started (will be inserted if not provided) + * * wpSectionTitle: the section to edit + * * wpMinorEdit: mark as minor edit + * * wpWatchthis: whether to watch the page + * @param int|null $expectedCode The expected result code (EditPage::AS_XXX constants). + * Set to null to skip the check. + * @param string|null $expectedText The text expected to be on the page after the edit. + * Set to null to skip the check. + * @param string|null $message An optional message to show along with any error message. + * + * @return WikiPage The page that was just edited, useful for getting the edit's rev_id, etc. + */ + protected function assertEdit( $title, $baseText, $user = null, array $edit, + $expectedCode = null, $expectedText = null, $message = null + ) { + if ( is_string( $title ) ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + } + $this->assertNotNull( $title ); + + if ( is_string( $user ) ) { + $user = User::newFromName( $user ); + + if ( $user->getId() === 0 ) { + $user->addToDatabase(); + } + } + + $page = WikiPage::factory( $title ); + + if ( $baseText !== null ) { + $content = ContentHandler::makeContent( $baseText, $title ); + $page->doEditContent( $content, "base text for test" ); + $this->forceRevisionDate( $page, '20120101000000' ); + + //sanity check + $page->clear(); + $currentText = ContentHandler::getContentText( $page->getContent() ); + + # EditPage rtrim() the user input, so we alter our expected text + # to reflect that. + $this->assertEditedTextEquals( $baseText, $currentText ); + } + + if ( $user == null ) { + $user = $GLOBALS['wgUser']; + } else { + $this->setMwGlobals( 'wgUser', $user ); + } + + if ( !isset( $edit['wpEditToken'] ) ) { + $edit['wpEditToken'] = $user->getEditToken(); + } + + if ( !isset( $edit['wpEdittime'] ) ) { + $edit['wpEdittime'] = $page->exists() ? $page->getTimestamp() : ''; + } + + if ( !isset( $edit['wpStarttime'] ) ) { + $edit['wpStarttime'] = wfTimestampNow(); + } + + $req = new FauxRequest( $edit, true ); // session ?? + + $article = new Article( $title ); + $article->getContext()->setTitle( $title ); + $ep = new EditPage( $article ); + $ep->setContextTitle( $title ); + $ep->importFormData( $req ); + + $bot = isset( $edit['bot'] ) ? (bool)$edit['bot'] : false; + + // this is where the edit happens! + // Note: don't want to use EditPage::AttemptSave, because it messes with $wgOut + // and throws exceptions like PermissionsError + $status = $ep->internalAttemptSave( $result, $bot ); + + if ( $expectedCode !== null ) { + // check edit code + $this->assertEquals( $expectedCode, $status->value, + "Expected result code mismatch. $message" ); + } + + $page = WikiPage::factory( $title ); + + if ( $expectedText !== null ) { + // check resulting page text + $content = $page->getContent(); + $text = ContentHandler::getContentText( $content ); + + # EditPage rtrim() the user input, so we alter our expected text + # to reflect that. + $this->assertEditedTextEquals( $expectedText, $text, + "Expected article text mismatch. $message" ); + } + + return $page; + } + + public static function provideCreatePages() { + return array( + array( 'expected article being created', + 'EditPageTest_testCreatePage', + null, + 'Hello World!', + EditPage::AS_SUCCESS_NEW_ARTICLE, + 'Hello World!' + ), + array( 'expected article not being created if empty', + 'EditPageTest_testCreatePage', + null, + '', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected MediaWiki: page being created', + 'MediaWiki:January', + 'UTSysop', + 'Not January', + EditPage::AS_SUCCESS_NEW_ARTICLE, + 'Not January' + ), + array( 'expected not-registered MediaWiki: page not being created if empty', + 'MediaWiki:EditPageTest_testCreatePage', + 'UTSysop', + '', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected registered MediaWiki: page being created even if empty', + 'MediaWiki:January', + 'UTSysop', + '', + EditPage::AS_SUCCESS_NEW_ARTICLE, + '' + ), + array( 'expected registered MediaWiki: page whose default content is empty not being created if empty', + 'MediaWiki:Ipb-default-expiry', + 'UTSysop', + '', + EditPage::AS_BLANK_ARTICLE, + '' + ), + array( 'expected MediaWiki: page not being created if text equals default message', + 'MediaWiki:January', + 'UTSysop', + 'January', + EditPage::AS_BLANK_ARTICLE, + null + ), + array( 'expected empty article being created', + 'EditPageTest_testCreatePage', + null, + '', + EditPage::AS_SUCCESS_NEW_ARTICLE, + '', + true + ), + ); + } + + /** + * @dataProvider provideCreatePages + * @covers EditPage + */ + public function testCreatePage( $desc, $pageTitle, $user, $editText, $expectedCode, $expectedText, $ignoreBlank = false ) { + $edit = array( 'wpTextbox1' => $editText ); + if ( $ignoreBlank ) { + $edit['wpIgnoreBlankArticle'] = 1; + } + + $page = $this->assertEdit( $pageTitle, null, $user, $edit, $expectedCode, $expectedText, $desc ); + + if ( $expectedCode != EditPage::AS_BLANK_ARTICLE ) { + $page->doDeleteArticleReal( $pageTitle ); + } + } + + public function testUpdatePage() { + $text = "one"; + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => 'first update', + ); + + $page = $this->assertEdit( 'EditPageTest_testUpdatePage', "zero", null, $edit, + EditPage::AS_SUCCESS_UPDATE, $text, + "expected successfull update with given text" ); + + $this->forceRevisionDate( $page, '20120101000000' ); + + $text = "two"; + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => 'second update', + ); + + $this->assertEdit( 'EditPageTest_testUpdatePage', null, null, $edit, + EditPage::AS_SUCCESS_UPDATE, $text, + "expected successfull update with given text" ); + } + + public static function provideSectionEdit() { + $text = 'Intro + +== one == +first section. + +== two == +second section. +'; + + $sectionOne = '== one == +hello +'; + + $newSection = '== new section == + +hello +'; + + $textWithNewSectionOne = preg_replace( + '/== one ==.*== two ==/ms', + "$sectionOne\n== two ==", $text + ); + + $textWithNewSectionAdded = "$text\n$newSection"; + + return array( + array( #0 + $text, + '', + 'hello', + 'replace all', + 'hello' + ), + + array( #1 + $text, + '1', + $sectionOne, + 'replace first section', + $textWithNewSectionOne, + ), + + array( #2 + $text, + 'new', + 'hello', + 'new section', + $textWithNewSectionAdded, + ), + ); + } + + /** + * @dataProvider provideSectionEdit + * @covers EditPage + */ + public function testSectionEdit( $base, $section, $text, $summary, $expected ) { + $edit = array( + 'wpTextbox1' => $text, + 'wpSummary' => $summary, + 'wpSection' => $section, + ); + + $this->assertEdit( 'EditPageTest_testSectionEdit', $base, null, $edit, + EditPage::AS_SUCCESS_UPDATE, $expected, + "expected successfull update of section" ); + } + + public static function provideAutoMerge() { + $tests = array(); + + $tests[] = array( #0: plain conflict + "Elmo", # base edit user + "one\n\ntwo\n\nthree\n", + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n", + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => "(one)\n\ntwo\n\nthree\n", + ), + EditPage::AS_CONFLICT_DETECTED, # expected code + "ONE\n\ntwo\n\nthree\n", # expected text + 'expected edit conflict', # message + ); + + $tests[] = array( #1: successful merge + "Elmo", # base edit user + "one\n\ntwo\n\nthree\n", + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => "ONE\n\ntwo\n\nthree\n", + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => "one\n\ntwo\n\nTHREE\n", + ), + EditPage::AS_SUCCESS_UPDATE, # expected code + "ONE\n\ntwo\n\nTHREE\n", # expected text + 'expected automatic merge', # message + ); + + $text = "Intro\n\n"; + $text .= "== first section ==\n\n"; + $text .= "one\n\ntwo\n\nthree\n\n"; + $text .= "== second section ==\n\n"; + $text .= "four\n\nfive\n\nsix\n\n"; + + // extract the first section. + $section = preg_replace( '/.*(== first section ==.*)== second section ==.*/sm', '$1', $text ); + + // generate expected text after merge + $expected = str_replace( 'one', 'ONE', str_replace( 'three', 'THREE', $text ) ); + + $tests[] = array( #2: merge in section + "Elmo", # base edit user + $text, + array( #adam's edit + 'wpStarttime' => 1, + 'wpTextbox1' => str_replace( 'one', 'ONE', $section ), + 'wpSection' => '1' + ), + array( #berta's edit + 'wpStarttime' => 2, + 'wpTextbox1' => str_replace( 'three', 'THREE', $section ), + 'wpSection' => '1' + ), + EditPage::AS_SUCCESS_UPDATE, # expected code + $expected, # expected text + 'expected automatic section merge', # message + ); + + // see whether it makes a difference who did the base edit + $testsWithAdam = array_map( function ( $test ) { + $test[0] = 'Adam'; // change base edit user + return $test; + }, $tests ); + + $testsWithBerta = array_map( function ( $test ) { + $test[0] = 'Berta'; // change base edit user + return $test; + }, $tests ); + + return array_merge( $tests, $testsWithAdam, $testsWithBerta ); + } + + /** + * @dataProvider provideAutoMerge + * @covers EditPage + */ + public function testAutoMerge( $baseUser, $text, $adamsEdit, $bertasEdit, + $expectedCode, $expectedText, $message = null + ) { + $this->checkHasDiff3(); + + //create page + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( 'EditPageTest_testAutoMerge', $ns ); + $page = WikiPage::factory( $title ); + + if ( $page->exists() ) { + $page->doDeleteArticle( "clean slate for testing" ); + } + + $baseEdit = array( + 'wpTextbox1' => $text, + ); + + $page = $this->assertEdit( 'EditPageTest_testAutoMerge', null, + $baseUser, $baseEdit, null, null, __METHOD__ ); + + $this->forceRevisionDate( $page, '20120101000000' ); + + $edittime = $page->getTimestamp(); + + // start timestamps for conflict detection + if ( !isset( $adamsEdit['wpStarttime'] ) ) { + $adamsEdit['wpStarttime'] = 1; + } + + if ( !isset( $bertasEdit['wpStarttime'] ) ) { + $bertasEdit['wpStarttime'] = 2; + } + + $starttime = wfTimestampNow(); + $adamsTime = wfTimestamp( + TS_MW, + (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$adamsEdit['wpStarttime'] + ); + $bertasTime = wfTimestamp( + TS_MW, + (int)wfTimestamp( TS_UNIX, $starttime ) + (int)$bertasEdit['wpStarttime'] + ); + + $adamsEdit['wpStarttime'] = $adamsTime; + $bertasEdit['wpStarttime'] = $bertasTime; + + $adamsEdit['wpSummary'] = 'Adam\'s edit'; + $bertasEdit['wpSummary'] = 'Bertas\'s edit'; + + $adamsEdit['wpEdittime'] = $edittime; + $bertasEdit['wpEdittime'] = $edittime; + + // first edit + $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Adam', $adamsEdit, + EditPage::AS_SUCCESS_UPDATE, null, "expected successfull update" ); + + // second edit + $this->assertEdit( 'EditPageTest_testAutoMerge', null, 'Berta', $bertasEdit, + $expectedCode, $expectedText, $message ); + } +} diff --git a/tests/phpunit/includes/ExternalStoreTest.php b/tests/phpunit/includes/ExternalStoreTest.php new file mode 100644 index 00000000..07c2957c --- /dev/null +++ b/tests/phpunit/includes/ExternalStoreTest.php @@ -0,0 +1,87 @@ +<?php +/** + * External Store tests + */ + +class ExternalStoreTest extends MediaWikiTestCase { + + /** + * @covers ExternalStore::fetchFromURL + */ + public function testExternalFetchFromURL() { + $this->setMwGlobals( 'wgExternalStores', false ); + + $this->assertFalse( + ExternalStore::fetchFromURL( 'FOO://cluster1/200' ), + 'Deny if wgExternalStores is not set to a non-empty array' + ); + + $this->setMwGlobals( 'wgExternalStores', array( 'FOO' ) ); + + $this->assertEquals( + ExternalStore::fetchFromURL( 'FOO://cluster1/200' ), + 'Hello', + 'Allow FOO://cluster1/200' + ); + $this->assertEquals( + ExternalStore::fetchFromURL( 'FOO://cluster1/300/0' ), + 'Hello', + 'Allow FOO://cluster1/300/0' + ); + # Assertions for r68900 + $this->assertFalse( + ExternalStore::fetchFromURL( 'ftp.example.org' ), + 'Deny domain ftp.example.org' + ); + $this->assertFalse( + ExternalStore::fetchFromURL( '/example.txt' ), + 'Deny path /example.txt' + ); + $this->assertFalse( + ExternalStore::fetchFromURL( 'http://' ), + 'Deny protocol http://' + ); + } +} + +class ExternalStoreFOO { + + protected $data = array( + 'cluster1' => array( + '200' => 'Hello', + '300' => array( + 'Hello', 'World', + ), + ), + ); + + /** + * Fetch data from given URL + * @param string $url An url of the form FOO://cluster/id or FOO://cluster/id/itemid. + * @return mixed + */ + function fetchFromURL( $url ) { + // Based on ExternalStoreDB + $path = explode( '/', $url ); + $cluster = $path[2]; + $id = $path[3]; + if ( isset( $path[4] ) ) { + $itemID = $path[4]; + } else { + $itemID = false; + } + + if ( !isset( $this->data[$cluster][$id] ) ) { + return null; + } + + if ( $itemID !== false + && is_array( $this->data[$cluster][$id] ) + && isset( $this->data[$cluster][$id][$itemID] ) + ) { + return $this->data[$cluster][$id][$itemID]; + } + + return $this->data[$cluster][$id]; + } +} diff --git a/tests/phpunit/includes/ExtraParserTest.php b/tests/phpunit/includes/ExtraParserTest.php new file mode 100644 index 00000000..4a4130e0 --- /dev/null +++ b/tests/phpunit/includes/ExtraParserTest.php @@ -0,0 +1,218 @@ +<?php + +/** + * Parser-related tests that don't suit for parserTests.txt + */ +class ExtraParserTest extends MediaWikiTestCase { + + /** @var ParserOptions */ + protected $options; + /** @var Parser */ + protected $parser; + + protected function setUp() { + parent::setUp(); + + $contLang = Language::factory( 'en' ); + $this->setMwGlobals( array( + 'wgShowDBErrorBacktrace' => true, + 'wgLanguageCode' => 'en', + 'wgContLang' => $contLang, + 'wgLang' => Language::factory( 'en' ), + 'wgMemc' => new EmptyBagOStuff, + 'wgAlwaysUseTidy' => false, + 'wgCleanSignatures' => true, + ) ); + + $this->options = ParserOptions::newFromUserAndLang( new User, $contLang ); + $this->options->setTemplateCallback( array( __CLASS__, 'statelessFetchTemplate' ) ); + $this->parser = new Parser; + + MagicWord::clearCache(); + } + + /** + * @see Bug 8689 + * @covers Parser::parse + */ + public function testLongNumericLinesDontKillTheParser() { + $longLine = '1.' . str_repeat( '1234567890', 100000 ) . "\n"; + + $title = Title::newFromText( 'Unit test' ); + $options = ParserOptions::newFromUser( new User() ); + $this->assertEquals( "<p>$longLine</p>", + $this->parser->parse( $longLine, $title, $options )->getText() ); + } + + /** + * Test the parser entry points + * @covers Parser::parse + */ + public function testParse() { + $title = Title::newFromText( __FUNCTION__ ); + $parserOutput = $this->parser->parse( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options ); + $this->assertEquals( + "<p>Test\nContent of <i>Template:Foo</i>\nContent of <i>Template:Bar</i>\n</p>", + $parserOutput->getText() + ); + } + + /** + * @covers Parser::preSaveTransform + */ + public function testPreSaveTransform() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->preSaveTransform( + "Test\r\n{{subst:Foo}}\n{{Bar}}", + $title, + new User(), + $this->options + ); + + $this->assertEquals( "Test\nContent of ''Template:Foo''\n{{Bar}}", $outputText ); + } + + /** + * @covers Parser::preprocess + */ + public function testPreprocess() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->preprocess( "Test\n{{Foo}}\n{{Bar}}", $title, $this->options ); + + $this->assertEquals( + "Test\nContent of ''Template:Foo''\nContent of ''Template:Bar''", + $outputText + ); + } + + /** + * cleanSig() makes all templates substs and removes tildes + * @covers Parser::cleanSig + */ + public function testCleanSig() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" ); + + $this->assertEquals( "{{SUBST:Foo}} ", $outputText ); + } + + /** + * cleanSig() should do nothing if disabled + * @covers Parser::cleanSig + */ + public function testCleanSigDisabled() { + $this->setMwGlobals( 'wgCleanSignatures', false ); + + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->cleanSig( "{{Foo}} ~~~~" ); + + $this->assertEquals( "{{Foo}} ~~~~", $outputText ); + } + + /** + * cleanSigInSig() just removes tildes + * @dataProvider provideStringsForCleanSigInSig + * @covers Parser::cleanSigInSig + */ + public function testCleanSigInSig( $in, $out ) { + $this->assertEquals( Parser::cleanSigInSig( $in ), $out ); + } + + public static function provideStringsForCleanSigInSig() { + return array( + array( "{{Foo}} ~~~~", "{{Foo}} " ), + array( "~~~", "" ), + array( "~~~~~", "" ), + ); + } + + /** + * @covers Parser::getSection + */ + public function testGetSection() { + $outputText2 = $this->parser->getSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 2 + ); + $outputText1 = $this->parser->getSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 1 + ); + + $this->assertEquals( "=== Heading 2 ===\nSection 2", $outputText2 ); + $this->assertEquals( "== Heading 1 ==\nSection 1\n=== Heading 2 ===\nSection 2", $outputText1 ); + } + + /** + * @covers Parser::replaceSection + */ + public function testReplaceSection() { + $outputText = $this->parser->replaceSection( + "Section 0\n== Heading 1 ==\nSection 1\n=== Heading 2 ===\n" + . "Section 2\n== Heading 3 ==\nSection 3\n", + 1, + "New section 1" + ); + + $this->assertEquals( "Section 0\nNew section 1\n\n== Heading 3 ==\nSection 3", $outputText ); + } + + /** + * Templates and comments are not affected, but noinclude/onlyinclude is. + * @covers Parser::getPreloadText + */ + public function testGetPreloadText() { + $title = Title::newFromText( __FUNCTION__ ); + $outputText = $this->parser->getPreloadText( + "{{Foo}}<noinclude> censored</noinclude> information <!-- is very secret -->", + $title, + $this->options + ); + + $this->assertEquals( "{{Foo}} information <!-- is very secret -->", $outputText ); + } + + /** + * @param Title $title + * @param bool $parser + * + * @return array + */ + static function statelessFetchTemplate( $title, $parser = false ) { + $text = "Content of ''" . $title->getFullText() . "''"; + $deps = array(); + + return array( + 'text' => $text, + 'finalTitle' => $title, + 'deps' => $deps ); + } + + /** + * @group Database + * @covers Parser::parse + */ + public function testTrackingCategory() { + $title = Title::newFromText( __FUNCTION__ ); + $catName = wfMessage( 'broken-file-category' )->inContentLanguage()->text(); + $cat = Title::makeTitleSafe( NS_CATEGORY, $catName ); + $expected = array( $cat->getDBkey() ); + $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options ); + $result = $parserOutput->getCategoryLinks(); + $this->assertEquals( $expected, $result ); + } + + /** + * @group Database + * @covers Parser::parse + */ + public function testTrackingCategorySpecial() { + // Special pages shouldn't have tracking cats. + $title = SpecialPage::getTitleFor( 'Contributions' ); + $parserOutput = $this->parser->parse( "[[file:nonexistent]]", $title, $this->options ); + $result = $parserOutput->getCategoryLinks(); + $this->assertEmpty( $result ); + } +} diff --git a/tests/phpunit/includes/FallbackTest.php b/tests/phpunit/includes/FallbackTest.php new file mode 100644 index 00000000..c60170f3 --- /dev/null +++ b/tests/phpunit/includes/FallbackTest.php @@ -0,0 +1,72 @@ +<?php + +/** + * @covers Fallback + */ +class FallbackTest extends MediaWikiTestCase { + public function testFallbackMbstringFunctions() { + if ( !extension_loaded( 'mbstring' ) ) { + $this->markTestSkipped( + "The mb_string functions must be installed to test the fallback functions" + ); + } + + $sampleUTF = "Östergötland_coat_of_arms.png"; + + //mb_substr + $substr_params = array( + array( 0, 0 ), + array( 5, -4 ), + array( 33 ), + array( 100, -5 ), + array( -8, 10 ), + array( 1, 1 ), + array( 2, -1 ) + ); + + foreach ( $substr_params as $param_set ) { + $old_param_set = $param_set; + array_unshift( $param_set, $sampleUTF ); + + $this->assertEquals( + call_user_func_array( 'mb_substr', $param_set ), + call_user_func_array( 'Fallback::mb_substr', $param_set ), + 'Fallback mb_substr with params ' . implode( ', ', $old_param_set ) + ); + } + + //mb_strlen + $this->assertEquals( + mb_strlen( $sampleUTF ), + Fallback::mb_strlen( $sampleUTF ), + 'Fallback mb_strlen' + ); + + //mb_str(r?)pos + $strpos_params = array( + //array( 'ter' ), + //array( 'Ö' ), + //array( 'Ö', 3 ), + //array( 'oat_', 100 ), + //array( 'c', -10 ), + //Broken for now + ); + + foreach ( $strpos_params as $param_set ) { + $old_param_set = $param_set; + array_unshift( $param_set, $sampleUTF ); + + $this->assertEquals( + call_user_func_array( 'mb_strpos', $param_set ), + call_user_func_array( 'Fallback::mb_strpos', $param_set ), + 'Fallback mb_strpos with params ' . implode( ', ', $old_param_set ) + ); + + $this->assertEquals( + call_user_func_array( 'mb_strrpos', $param_set ), + call_user_func_array( 'Fallback::mb_strrpos', $param_set ), + 'Fallback mb_strrpos with params ' . implode( ', ', $old_param_set ) + ); + } + } +} diff --git a/tests/phpunit/includes/FauxRequestTest.php b/tests/phpunit/includes/FauxRequestTest.php new file mode 100644 index 00000000..745a5b42 --- /dev/null +++ b/tests/phpunit/includes/FauxRequestTest.php @@ -0,0 +1,18 @@ +<?php + +class FauxRequestTest extends MediaWikiTestCase { + /** + * @covers FauxRequest::setHeader + * @covers FauxRequest::getHeader + */ + public function testGetSetHeader() { + $value = 'test/test'; + + $request = new FauxRequest(); + $request->setHeader( 'Content-Type', $value ); + + $this->assertEquals( $request->getHeader( 'Content-Type' ), $value ); + $this->assertEquals( $request->getHeader( 'CONTENT-TYPE' ), $value ); + $this->assertEquals( $request->getHeader( 'content-type' ), $value ); + } +} diff --git a/tests/phpunit/includes/FauxResponseTest.php b/tests/phpunit/includes/FauxResponseTest.php new file mode 100644 index 00000000..4a974ba2 --- /dev/null +++ b/tests/phpunit/includes/FauxResponseTest.php @@ -0,0 +1,118 @@ +<?php +/** + * Tests for the FauxResponse class + * + * Copyright @ 2011 Alexandre Emsenhuber + * + * 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 + */ + +class FauxResponseTest extends MediaWikiTestCase { + /** @var FauxResponse */ + protected $response; + + protected function setUp() { + parent::setUp(); + $this->response = new FauxResponse; + } + + /** + * @covers FauxResponse::getcookie + * @covers FauxResponse::setcookie + */ + public function testCookie() { + $this->assertEquals( null, $this->response->getcookie( 'key' ), 'Non-existing cookie' ); + $this->response->setcookie( 'key', 'val' ); + $this->assertEquals( 'val', $this->response->getcookie( 'key' ), 'Existing cookie' ); + } + + /** + * @covers FauxResponse::getheader + * @covers FauxResponse::header + */ + public function testHeader() { + $this->assertEquals( null, $this->response->getheader( 'Location' ), 'Non-existing header' ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getheader( 'Location' ), + 'Set header' + ); + + $this->response->header( 'Location: http://127.0.0.1/' ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getheader( 'Location' ), + 'Same header' + ); + + $this->response->header( 'Location: http://127.0.0.2/', false ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getheader( 'Location' ), + 'Same header with override disabled' + ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getheader( 'LOCATION' ), + 'Get header case insensitive' + ); + } + + /** + * @covers FauxResponse::getStatusCode + */ + public function testResponseCode() { + $this->response->header( 'HTTP/1.1 200' ); + $this->assertEquals( 200, $this->response->getStatusCode(), 'Header with no message' ); + + $this->response->header( 'HTTP/1.x 201' ); + $this->assertEquals( + 201, + $this->response->getStatusCode(), + 'Header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.1 202 OK' ); + $this->assertEquals( 202, $this->response->getStatusCode(), 'Normal header' ); + + $this->response->header( 'HTTP/1.x 203 OK' ); + $this->assertEquals( + 203, + $this->response->getStatusCode(), + 'Normal header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.x 204 OK', false, 205 ); + $this->assertEquals( + 205, + $this->response->getStatusCode(), + 'Third parameter overrides the HTTP/... header' + ); + + $this->response->header( 'Location: http://localhost/', false, 206 ); + $this->assertEquals( + 206, + $this->response->getStatusCode(), + 'Third parameter with another header' + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsInitializationTest.php b/tests/phpunit/includes/FormOptionsInitializationTest.php new file mode 100644 index 00000000..1531b569 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsInitializationTest.php @@ -0,0 +1,89 @@ +<?php +/** + * This file host two test case classes for the MediaWiki FormOptions class: + * - FormOptionsInitializationTest : tests initialization of the class. + * - FormOptionsTest : tests methods an on instance + * + * The split let us take advantage of setting up a fixture for the methods + * tests. + */ + +/** + * Dummy class to makes FormOptions::$options public. + * Used by FormOptionsInitializationTest which need to verify the $options + * array is correctly set through the FormOptions::add() function. + */ +class FormOptionsExposed extends FormOptions { + public function getOptions() { + return $this->options; + } +} + +/** + * Test class for FormOptions initialization + * Ensure the FormOptions::add() does what we want it to do. + * + * Generated by PHPUnit on 2011-02-28 at 20:46:27. + * + * Copyright © 2011, Antoine Musso + * + * @author Antoine Musso + */ +class FormOptionsInitializationTest extends MediaWikiTestCase { + /** + * @var FormOptions + */ + protected $object; + + /** + * A new fresh and empty FormOptions object to test initialization + * with. + */ + protected function setUp() { + parent::setUp(); + $this->object = new FormOptionsExposed(); + } + + /** + * @covers FormOptionsExposed::add + */ + public function testAddStringOption() { + $this->object->add( 'foo', 'string value' ); + $this->assertEquals( + array( + 'foo' => array( + 'default' => 'string value', + 'consumed' => false, + 'type' => FormOptions::STRING, + 'value' => null, + ) + ), + $this->object->getOptions() + ); + } + + /** + * @covers FormOptionsExposed::add + */ + public function testAddIntegers() { + $this->object->add( 'one', 1 ); + $this->object->add( 'negone', -1 ); + $this->assertEquals( + array( + 'negone' => array( + 'default' => -1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ), + 'one' => array( + 'default' => 1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ) + ), + $this->object->getOptions() + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsTest.php b/tests/phpunit/includes/FormOptionsTest.php new file mode 100644 index 00000000..665fa390 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsTest.php @@ -0,0 +1,103 @@ +<?php +/** + * This file host two test case classes for the MediaWiki FormOptions class: + * - FormOptionsInitializationTest : tests initialization of the class. + * - FormOptionsTest : tests methods an on instance + * + * The split let us take advantage of setting up a fixture for the methods + * tests. + */ + +/** + * Test class for FormOptions methods. + * Generated by PHPUnit on 2011-02-28 at 20:46:27. + * + * Copyright © 2011, Antoine Musso + * + * @author Antoine Musso + */ +class FormOptionsTest extends MediaWikiTestCase { + /** + * @var FormOptions + */ + protected $object; + + /** + * Instanciates a FormOptions object to play with. + * FormOptions::add() is tested by the class FormOptionsInitializationTest + * so we assume the function is well tested already an use it to create + * the fixture. + */ + protected function setUp() { + parent::setUp(); + $this->object = new FormOptions; + $this->object->add( 'string1', 'string one' ); + $this->object->add( 'string2', 'string two' ); + $this->object->add( 'integer', 0 ); + $this->object->add( 'float', 0.0 ); + $this->object->add( 'intnull', 0, FormOptions::INTNULL ); + } + + /** Helpers for testGuessType() */ + /* @{ */ + private function assertGuessBoolean( $data ) { + $this->guess( FormOptions::BOOL, $data ); + } + private function assertGuessInt( $data ) { + $this->guess( FormOptions::INT, $data ); + } + private function assertGuessFloat( $data ) { + $this->guess( FormOptions::FLOAT, $data ); + } + private function assertGuessString( $data ) { + $this->guess( FormOptions::STRING, $data ); + } + + /** Generic helper */ + private function guess( $expected, $data ) { + $this->assertEquals( + $expected, + FormOptions::guessType( $data ) + ); + } + /* @} */ + + /** + * Reuse helpers above assertGuessBoolean assertGuessInt assertGuessString + * @covers FormOptions::guessType + */ + public function testGuessTypeDetection() { + $this->assertGuessBoolean( true ); + $this->assertGuessBoolean( false ); + + $this->assertGuessInt( 0 ); + $this->assertGuessInt( -5 ); + $this->assertGuessInt( 5 ); + $this->assertGuessInt( 0x0F ); + + $this->assertGuessFloat( 0.0 ); + $this->assertGuessFloat( 1.5 ); + $this->assertGuessFloat( 1e3 ); + + $this->assertGuessString( 'true' ); + $this->assertGuessString( 'false' ); + $this->assertGuessString( '5' ); + $this->assertGuessString( '0' ); + $this->assertGuessString( '1.5' ); + } + + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnArrayThrowException() { + $this->object->guessType( array( 'foo' ) ); + } + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnNullThrowException() { + $this->object->guessType( null ); + } +} diff --git a/tests/phpunit/includes/GitInfoTest.php b/tests/phpunit/includes/GitInfoTest.php new file mode 100644 index 00000000..e22f5050 --- /dev/null +++ b/tests/phpunit/includes/GitInfoTest.php @@ -0,0 +1,42 @@ +<?php +/** + * @covers GitInfo + */ +class GitInfoTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( 'wgGitInfoCacheDirectory', __DIR__ . '/../data/gitinfo' ); + } + + public function testValidJsonData() { + $dir = $GLOBALS['IP'] . '/testValidJsonData'; + $fixture = new GitInfo( $dir ); + + $this->assertTrue( $fixture->cacheIsComplete() ); + $this->assertEquals( 'refs/heads/master', $fixture->getHead() ); + $this->assertEquals( '0123456789abcdef0123456789abcdef01234567', + $fixture->getHeadSHA1() ); + $this->assertEquals( '1070884800', $fixture->getHeadCommitDate() ); + $this->assertEquals( 'master', $fixture->getCurrentBranch() ); + $this->assertContains( '0123456789abcdef0123456789abcdef01234567', + $fixture->getHeadViewUrl() ); + } + + public function testMissingJsonData() { + $dir = $GLOBALS['IP'] . '/testMissingJsonData'; + $fixture = new GitInfo( $dir ); + + $this->assertFalse( $fixture->cacheIsComplete() ); + + $this->assertEquals( false, $fixture->getHead() ); + $this->assertEquals( false, $fixture->getHeadSHA1() ); + $this->assertEquals( false, $fixture->getHeadCommitDate() ); + $this->assertEquals( false, $fixture->getCurrentBranch() ); + $this->assertEquals( false, $fixture->getHeadViewUrl() ); + + // After calling all the outputs, the cache should be complete + $this->assertTrue( $fixture->cacheIsComplete() ); + } + +} diff --git a/tests/phpunit/includes/GlobalFunctions/GlobalTest.php b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php new file mode 100644 index 00000000..3acc48e2 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/GlobalTest.php @@ -0,0 +1,745 @@ +<?php + +/** + * @group GlobalFunctions + */ +class GlobalTest extends MediaWikiTestCase { + protected function setUp() { + parent::setUp(); + + $readOnlyFile = tempnam( wfTempDir(), "mwtest_readonly" ); + unlink( $readOnlyFile ); + + $this->setMwGlobals( array( + 'wgReadOnlyFile' => $readOnlyFile, + 'wgUrlProtocols' => array( + 'http://', + 'https://', + 'mailto:', + '//', + 'file://', # Non-default + ), + ) ); + } + + protected function tearDown() { + global $wgReadOnlyFile; + + if ( file_exists( $wgReadOnlyFile ) ) { + unlink( $wgReadOnlyFile ); + } + + parent::tearDown(); + } + + /** + * @dataProvider provideForWfArrayDiff2 + * @covers ::wfArrayDiff2 + */ + public function testWfArrayDiff2( $a, $b, $expected ) { + $this->assertEquals( + wfArrayDiff2( $a, $b ), $expected + ); + } + + // @todo Provide more tests + public static function provideForWfArrayDiff2() { + // $a $b $expected + return array( + array( + array( 'a', 'b' ), + array( 'a', 'b' ), + array(), + ), + array( + array( array( 'a' ), array( 'a', 'b', 'c' ) ), + array( array( 'a' ), array( 'a', 'b' ) ), + array( 1 => array( 'a', 'b', 'c' ) ), + ), + ); + } + + /* + * Test cases for random functions could hypothetically fail, + * even though they shouldn't. + */ + + /** + * @covers ::wfRandom + */ + public function testRandom() { + $this->assertFalse( + wfRandom() == wfRandom() + ); + } + + /** + * @covers ::wfRandomString + */ + public function testRandomString() { + $this->assertFalse( + wfRandomString() == wfRandomString() + ); + $this->assertEquals( + strlen( wfRandomString( 10 ) ), 10 + ); + $this->assertTrue( + preg_match( '/^[0-9a-f]+$/i', wfRandomString() ) === 1 + ); + } + + /** + * @covers ::wfUrlencode + */ + public function testUrlencode() { + $this->assertEquals( + "%E7%89%B9%E5%88%A5:Contributions/Foobar", + wfUrlencode( "\xE7\x89\xB9\xE5\x88\xA5:Contributions/Foobar" ) ); + } + + /** + * @covers ::wfExpandIRI + */ + public function testExpandIRI() { + $this->assertEquals( + "https://te.wikibooks.org/wiki/ఉబుంటు_వాడుకరి_మార్గదర్శని", + wfExpandIRI( "https://te.wikibooks.org/wiki/" + . "%E0%B0%89%E0%B0%AC%E0%B1%81%E0%B0%82%E0%B0%9F%E0%B1%81_" + . "%E0%B0%B5%E0%B0%BE%E0%B0%A1%E0%B1%81%E0%B0%95%E0%B0%B0%E0%B0%BF_" + . "%E0%B0%AE%E0%B0%BE%E0%B0%B0%E0%B1%8D%E0%B0%97%E0%B0%A6%E0%B0%B0" + . "%E0%B1%8D%E0%B0%B6%E0%B0%A8%E0%B0%BF" ) ); + } + + /** + * @covers ::wfReadOnly + */ + public function testReadOnlyEmpty() { + global $wgReadOnly; + $wgReadOnly = null; + + $this->assertFalse( wfReadOnly() ); + $this->assertFalse( wfReadOnly() ); + } + + /** + * @covers ::wfReadOnly + */ + public function testReadOnlySet() { + global $wgReadOnly, $wgReadOnlyFile; + + $f = fopen( $wgReadOnlyFile, "wt" ); + fwrite( $f, 'Message' ); + fclose( $f ); + $wgReadOnly = null; # Check on $wgReadOnlyFile + + $this->assertTrue( wfReadOnly() ); + $this->assertTrue( wfReadOnly() ); # Check cached + + unlink( $wgReadOnlyFile ); + $wgReadOnly = null; # Clean cache + + $this->assertFalse( wfReadOnly() ); + $this->assertFalse( wfReadOnly() ); + } + + public static function provideArrayToCGI() { + return array( + array( array(), '' ), // empty + array( array( 'foo' => 'bar' ), 'foo=bar' ), // string test + array( array( 'foo' => '' ), 'foo=' ), // empty string test + array( array( 'foo' => 1 ), 'foo=1' ), // number test + array( array( 'foo' => true ), 'foo=1' ), // true test + array( array( 'foo' => false ), '' ), // false test + array( array( 'foo' => null ), '' ), // null test + array( array( 'foo' => 'A&B=5+6@!"\'' ), 'foo=A%26B%3D5%2B6%40%21%22%27' ), // urlencoding test + array( + array( 'foo' => 'bar', 'baz' => 'is', 'asdf' => 'qwerty' ), + 'foo=bar&baz=is&asdf=qwerty' + ), // multi-item test + array( array( 'foo' => array( 'bar' => 'baz' ) ), 'foo%5Bbar%5D=baz' ), + array( + array( 'foo' => array( 'bar' => 'baz', 'qwerty' => 'asdf' ) ), + 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf' + ), + array( array( 'foo' => array( 'bar', 'baz' ) ), 'foo%5B0%5D=bar&foo%5B1%5D=baz' ), + array( + array( 'foo' => array( 'bar' => array( 'bar' => 'baz' ) ) ), + 'foo%5Bbar%5D%5Bbar%5D=baz' + ), + ); + } + + /** + * @dataProvider provideArrayToCGI + * @covers ::wfArrayToCgi + */ + public function testArrayToCGI( $array, $result ) { + $this->assertEquals( $result, wfArrayToCgi( $array ) ); + } + + /** + * @covers ::wfArrayToCgi + */ + public function testArrayToCGI2() { + $this->assertEquals( + "baz=bar&foo=bar", + wfArrayToCgi( + array( 'baz' => 'bar' ), + array( 'foo' => 'bar', 'baz' => 'overridden value' ) ) ); + } + + public static function provideCgiToArray() { + return array( + array( '', array() ), // empty + array( 'foo=bar', array( 'foo' => 'bar' ) ), // string + array( 'foo=', array( 'foo' => '' ) ), // empty string + array( 'foo', array( 'foo' => '' ) ), // missing = + array( 'foo=bar&qwerty=asdf', array( 'foo' => 'bar', 'qwerty' => 'asdf' ) ), // multiple value + array( 'foo=A%26B%3D5%2B6%40%21%22%27', array( 'foo' => 'A&B=5+6@!"\'' ) ), // urldecoding test + array( 'foo%5Bbar%5D=baz', array( 'foo' => array( 'bar' => 'baz' ) ) ), + array( + 'foo%5Bbar%5D=baz&foo%5Bqwerty%5D=asdf', + array( 'foo' => array( 'bar' => 'baz', 'qwerty' => 'asdf' ) ) + ), + array( 'foo%5B0%5D=bar&foo%5B1%5D=baz', array( 'foo' => array( 0 => 'bar', 1 => 'baz' ) ) ), + array( + 'foo%5Bbar%5D%5Bbar%5D=baz', + array( 'foo' => array( 'bar' => array( 'bar' => 'baz' ) ) ) + ), + ); + } + + /** + * @dataProvider provideCgiToArray + * @covers ::wfCgiToArray + */ + public function testCgiToArray( $cgi, $result ) { + $this->assertEquals( $result, wfCgiToArray( $cgi ) ); + } + + public static function provideCgiRoundTrip() { + return array( + array( '' ), + array( 'foo=bar' ), + array( 'foo=' ), + array( 'foo=bar&baz=biz' ), + array( 'foo=A%26B%3D5%2B6%40%21%22%27' ), + array( 'foo%5Bbar%5D=baz' ), + array( 'foo%5B0%5D=bar&foo%5B1%5D=baz' ), + array( 'foo%5Bbar%5D%5Bbar%5D=baz' ), + ); + } + + /** + * @dataProvider provideCgiRoundTrip + * @covers ::wfArrayToCgi + */ + public function testCgiRoundTrip( $cgi ) { + $this->assertEquals( $cgi, wfArrayToCgi( wfCgiToArray( $cgi ) ) ); + } + + /** + * @covers ::mimeTypeMatch + */ + public function testMimeTypeMatch() { + $this->assertEquals( + 'text/html', + mimeTypeMatch( 'text/html', + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.3 ) ) ); + $this->assertEquals( + 'text/*', + mimeTypeMatch( 'text/html', + array( 'image/*' => 1.0, + 'text/*' => 0.5 ) ) ); + $this->assertEquals( + '*/*', + mimeTypeMatch( 'text/html', + array( '*/*' => 1.0 ) ) ); + $this->assertNull( + mimeTypeMatch( 'text/html', + array( 'image/png' => 1.0, + 'image/svg+xml' => 0.5 ) ) ); + } + + /** + * @covers ::wfNegotiateType + */ + public function testNegotiateType() { + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.5, + 'text/*' => 0.2 ), + array( 'text/html' => 1.0 ) ) ); + $this->assertEquals( + 'application/xhtml+xml', + wfNegotiateType( + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.7, + 'text/plain' => 0.5, + 'text/*' => 0.2 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'text/html' => 1.0, + 'text/plain' => 0.5, + 'text/*' => 0.5, + 'application/xhtml+xml' => 0.2 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertEquals( + 'text/html', + wfNegotiateType( + array( 'text/*' => 1.0, + 'image/*' => 0.7, + '*/*' => 0.3 ), + array( 'application/xhtml+xml' => 1.0, + 'text/html' => 0.5 ) ) ); + $this->assertNull( + wfNegotiateType( + array( 'text/*' => 1.0 ), + array( 'application/xhtml+xml' => 1.0 ) ) ); + } + + /** + * @covers ::wfDebug + * @covers ::wfDebugMem + */ + public function testDebugFunctionTest() { + + global $wgDebugLogFile, $wgDebugTimestamps; + + $old_log_file = $wgDebugLogFile; + $wgDebugLogFile = tempnam( wfTempDir(), 'mw-' ); + # @todo FIXME: $wgDebugTimestamps should be tested + $old_wgDebugTimestamps = $wgDebugTimestamps; + $wgDebugTimestamps = false; + + wfDebug( "This is a normal string" ); + $this->assertEquals( "This is a normal string", file_get_contents( $wgDebugLogFile ) ); + unlink( $wgDebugLogFile ); + + wfDebug( "This is nöt an ASCII string" ); + $this->assertEquals( "This is nöt an ASCII string", file_get_contents( $wgDebugLogFile ) ); + unlink( $wgDebugLogFile ); + + wfDebug( "\00305This has böth UTF and control chars\003" ); + $this->assertEquals( + " 05This has böth UTF and control chars ", + file_get_contents( $wgDebugLogFile ) + ); + unlink( $wgDebugLogFile ); + + wfDebugMem(); + $this->assertGreaterThan( + 1000, + preg_replace( '/\D/', '', file_get_contents( $wgDebugLogFile ) ) + ); + unlink( $wgDebugLogFile ); + + wfDebugMem( true ); + $this->assertGreaterThan( + 1000000, + preg_replace( '/\D/', '', file_get_contents( $wgDebugLogFile ) ) + ); + unlink( $wgDebugLogFile ); + + $wgDebugLogFile = $old_log_file; + $wgDebugTimestamps = $old_wgDebugTimestamps; + } + + /** + * @covers ::wfClientAcceptsGzip + */ + public function testClientAcceptsGzipTest() { + + $settings = array( + 'gzip' => true, + 'bzip' => false, + '*' => false, + 'compress, gzip' => true, + 'gzip;q=1.0' => true, + 'foozip' => false, + 'foo*zip' => false, + 'gzip;q=abcde' => true, //is this REALLY valid? + 'gzip;q=12345678.9' => true, + ' gzip' => true, + ); + + if ( isset( $_SERVER['HTTP_ACCEPT_ENCODING'] ) ) { + $old_server_setting = $_SERVER['HTTP_ACCEPT_ENCODING']; + } + + foreach ( $settings as $encoding => $expect ) { + $_SERVER['HTTP_ACCEPT_ENCODING'] = $encoding; + + $this->assertEquals( $expect, wfClientAcceptsGzip( true ), + "'$encoding' => " . wfBoolToStr( $expect ) ); + } + + if ( isset( $old_server_setting ) ) { + $_SERVER['HTTP_ACCEPT_ENCODING'] = $old_server_setting; + } + } + + /** + * @covers ::swap + */ + public function testSwapVarsTest() { + $this->hideDeprecated( 'swap' ); + + $var1 = 1; + $var2 = 2; + + $this->assertEquals( $var1, 1, 'var1 is set originally' ); + $this->assertEquals( $var2, 2, 'var1 is set originally' ); + + swap( $var1, $var2 ); + + $this->assertEquals( $var1, 2, 'var1 is swapped' ); + $this->assertEquals( $var2, 1, 'var2 is swapped' ); + } + + /** + * @covers ::wfPercent + */ + public function testWfPercentTest() { + + $pcts = array( + array( 6 / 7, '0.86%', 2, false ), + array( 3 / 3, '1%' ), + array( 22 / 7, '3.14286%', 5 ), + array( 3 / 6, '0.5%' ), + array( 1 / 3, '0%', 0 ), + array( 10 / 3, '0%', -1 ), + array( 3 / 4 / 5, '0.1%', 1 ), + array( 6 / 7 * 8, '6.8571428571%', 10 ), + ); + + foreach ( $pcts as $pct ) { + if ( !isset( $pct[2] ) ) { + $pct[2] = 2; + } + if ( !isset( $pct[3] ) ) { + $pct[3] = true; + } + + $this->assertEquals( wfPercent( $pct[0], $pct[2], $pct[3] ), $pct[1], $pct[1] ); + } + } + + /** + * test @see wfShorthandToInteger() + * @dataProvider provideShorthand + * @covers ::wfShorthandToInteger + */ + public function testWfShorthandToInteger( $shorthand, $expected ) { + $this->assertEquals( $expected, + wfShorthandToInteger( $shorthand ) + ); + } + + /** array( shorthand, expected integer ) */ + public static function provideShorthand() { + return array( + # Null, empty ... + array( '', -1 ), + array( ' ', -1 ), + array( null, -1 ), + + # Failures returns 0 :( + array( 'ABCDEFG', 0 ), + array( 'Ak', 0 ), + + # Int, strings with spaces + array( 1, 1 ), + array( ' 1 ', 1 ), + array( 1023, 1023 ), + array( ' 1023 ', 1023 ), + + # kilo, Mega, Giga + array( '1k', 1024 ), + array( '1K', 1024 ), + array( '1m', 1024 * 1024 ), + array( '1M', 1024 * 1024 ), + array( '1g', 1024 * 1024 * 1024 ), + array( '1G', 1024 * 1024 * 1024 ), + + # Negatives + array( -1, -1 ), + array( -500, -500 ), + array( '-500', -500 ), + array( '-1k', -1024 ), + + # Zeroes + array( '0', 0 ), + array( '0k', 0 ), + array( '0M', 0 ), + array( '0G', 0 ), + array( '-0', 0 ), + array( '-0k', 0 ), + array( '-0M', 0 ), + array( '-0G', 0 ), + ); + } + + /** + * @param string $old Text as it was in the database + * @param string $mine Text submitted while user was editing + * @param string $yours Text submitted by the user + * @param bool $expectedMergeResult Whether the merge should be a success + * @param string $expectedText Text after merge has been completed + * + * @dataProvider provideMerge() + * @group medium + * @covers ::wfMerge + */ + public function testMerge( $old, $mine, $yours, $expectedMergeResult, $expectedText ) { + $this->checkHasDiff3(); + + $mergedText = null; + $isMerged = wfMerge( $old, $mine, $yours, $mergedText ); + + $msg = 'Merge should be a '; + $msg .= $expectedMergeResult ? 'success' : 'failure'; + $this->assertEquals( $expectedMergeResult, $isMerged, $msg ); + + if ( $isMerged ) { + // Verify the merged text + $this->assertEquals( $expectedText, $mergedText, + 'is merged text as expected?' ); + } + } + + public static function provideMerge() { + $EXPECT_MERGE_SUCCESS = true; + $EXPECT_MERGE_FAILURE = false; + + return array( + // #0: clean merge + array( + // old: + "one one one\n" . // trimmed + "\n" . + "two two two", + + // mine: + "one one one ONE ONE\n" . + "\n" . + "two two two\n", // with tailing whitespace + + // yours: + "one one one\n" . + "\n" . + "two two TWO TWO", // trimmed + + // ok: + $EXPECT_MERGE_SUCCESS, + + // result: + "one one one ONE ONE\n" . + "\n" . + "two two TWO TWO\n", // note: will always end in a newline + ), + + // #1: conflict, fail + array( + // old: + "one one one", // trimmed + + // mine: + "one one one ONE ONE\n" . + "\n" . + "bla bla\n" . + "\n", // with tailing whitespace + + // yours: + "one one one\n" . + "\n" . + "two two", // trimmed + + $EXPECT_MERGE_FAILURE, + + // result: + null, + ), + ); + } + + /** + * @dataProvider provideMakeUrlIndexes() + * @covers ::wfMakeUrlIndexes + */ + public function testMakeUrlIndexes( $url, $expected ) { + $index = wfMakeUrlIndexes( $url ); + $this->assertEquals( $expected, $index, "wfMakeUrlIndexes(\"$url\")" ); + } + + public static function provideMakeUrlIndexes() { + return array( + array( + // just a regular :) + 'https://bugzilla.wikimedia.org/show_bug.cgi?id=28627', + array( 'https://org.wikimedia.bugzilla./show_bug.cgi?id=28627' ) + ), + array( + // mailtos are handled special + // is this really right though? that final . probably belongs earlier? + 'mailto:wiki@wikimedia.org', + array( 'mailto:org.wikimedia@wiki.' ) + ), + + // file URL cases per bug 28627... + array( + // three slashes: local filesystem path Unix-style + 'file:///whatever/you/like.txt', + array( 'file://./whatever/you/like.txt' ) + ), + array( + // three slashes: local filesystem path Windows-style + 'file:///c:/whatever/you/like.txt', + array( 'file://./c:/whatever/you/like.txt' ) + ), + array( + // two slashes: UNC filesystem path Windows-style + 'file://intranet/whatever/you/like.txt', + array( 'file://intranet./whatever/you/like.txt' ) + ), + // Multiple-slash cases that can sorta work on Mozilla + // if you hack it just right are kinda pathological, + // and unreliable cross-platform or on IE which means they're + // unlikely to appear on intranets. + // + // Those will survive the algorithm but with results that + // are less consistent. + + // protocol-relative URL cases per bug 29854... + array( + '//bugzilla.wikimedia.org/show_bug.cgi?id=28627', + array( + 'http://org.wikimedia.bugzilla./show_bug.cgi?id=28627', + 'https://org.wikimedia.bugzilla./show_bug.cgi?id=28627' + ) + ), + ); + } + + /** + * @dataProvider provideWfMatchesDomainList + * @covers ::wfMatchesDomainList + */ + public function testWfMatchesDomainList( $url, $domains, $expected, $description ) { + $actual = wfMatchesDomainList( $url, $domains ); + $this->assertEquals( $expected, $actual, $description ); + } + + public static function provideWfMatchesDomainList() { + $a = array(); + $protocols = array( 'HTTP' => 'http:', 'HTTPS' => 'https:', 'protocol-relative' => '' ); + foreach ( $protocols as $pDesc => $p ) { + $a = array_merge( $a, array( + array( + "$p//www.example.com", + array(), + false, + "No matches for empty domains array, $pDesc URL" + ), + array( + "$p//www.example.com", + array( 'www.example.com' ), + true, + "Exact match in domains array, $pDesc URL" + ), + array( + "$p//www.example.com", + array( 'example.com' ), + true, + "Match without subdomain in domains array, $pDesc URL" + ), + array( + "$p//www.example2.com", + array( 'www.example.com', 'www.example2.com', 'www.example3.com' ), + true, + "Exact match with other domains in array, $pDesc URL" + ), + array( + "$p//www.example2.com", + array( 'example.com', 'example2.com', 'example3,com' ), + true, + "Match without subdomain with other domains in array, $pDesc URL" + ), + array( + "$p//www.example4.com", + array( 'example.com', 'example2.com', 'example3,com' ), + false, + "Domain not in array, $pDesc URL" + ), + array( + "$p//nds-nl.wikipedia.org", + array( 'nl.wikipedia.org' ), + false, + "Non-matching substring of domain, $pDesc URL" + ), + ) ); + } + + return $a; + } + + /** + * @covers ::wfMkdirParents + */ + public function testWfMkdirParents() { + // Should not return true if file exists instead of directory + $fname = $this->getNewTempFile(); + wfSuppressWarnings(); + $ok = wfMkdirParents( $fname ); + wfRestoreWarnings(); + $this->assertFalse( $ok ); + } + + /** + * @dataProvider provideWfShellMaintenanceCmdList + * @covers ::wfShellMaintenanceCmd + */ + public function testWfShellMaintenanceCmd( $script, $parameters, $options, + $expected, $description + ) { + if ( wfIsWindows() ) { + // Approximation that's good enough for our purposes just now + $expected = str_replace( "'", '"', $expected ); + } + $actual = wfShellMaintenanceCmd( $script, $parameters, $options ); + $this->assertEquals( $expected, $actual, $description ); + } + + public static function provideWfShellMaintenanceCmdList() { + global $wgPhpCli; + + return array( + array( 'eval.php', array( '--help', '--test' ), array(), + "'$wgPhpCli' 'eval.php' '--help' '--test'", + "Called eval.php --help --test" ), + array( 'eval.php', array( '--help', '--test space' ), array( 'php' => 'php5' ), + "'php5' 'eval.php' '--help' '--test space'", + "Called eval.php --help --test with php option" ), + array( 'eval.php', array( '--help', '--test', 'X' ), array( 'wrapper' => 'MWScript.php' ), + "'$wgPhpCli' 'MWScript.php' 'eval.php' '--help' '--test' 'X'", + "Called eval.php --help --test with wrapper option" ), + array( + 'eval.php', + array( '--help', '--test', 'y' ), + array( 'php' => 'php5', 'wrapper' => 'MWScript.php' ), + "'php5' 'MWScript.php' 'eval.php' '--help' '--test' 'y'", + "Called eval.php --help --test with wrapper and php option" + ), + ); + } + /* @todo many more! */ +} diff --git a/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php b/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php new file mode 100644 index 00000000..9588ffdc --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/GlobalWithDBTest.php @@ -0,0 +1,32 @@ +<?php + +/** + * @group GlobalFunctions + * @group Database + */ +class GlobalWithDBTest extends MediaWikiTestCase { + /** + * @dataProvider provideWfIsBadImageList + * @covers ::wfIsBadImage + */ + public function testWfIsBadImage( $name, $title, $blacklist, $expected, $desc ) { + $this->assertEquals( $expected, wfIsBadImage( $name, $title, $blacklist ), $desc ); + } + + public static function provideWfIsBadImageList() { + $blacklist = '* [[File:Bad.jpg]] except [[Nasty page]]'; + + return array( + array( 'Bad.jpg', false, $blacklist, true, + 'Called on a bad image' ), + array( 'Bad.jpg', Title::makeTitle( NS_MAIN, 'A page' ), $blacklist, true, + 'Called on a bad image' ), + array( 'NotBad.jpg', false, $blacklist, false, + 'Called on a non-bad image' ), + array( 'Bad.jpg', Title::makeTitle( NS_MAIN, 'Nasty page' ), $blacklist, false, + 'Called on a bad image but is on a whitelisted page' ), + array( 'File:Bad.jpg', false, $blacklist, false, + 'Called on a bad image with File:' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/README b/tests/phpunit/includes/GlobalFunctions/README new file mode 100644 index 00000000..0042bdac --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/README @@ -0,0 +1,2 @@ +This directory hold tests for includes/GlobalFunctions.php file +which is a pile of functions. diff --git a/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php new file mode 100644 index 00000000..13f49f79 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php @@ -0,0 +1,112 @@ +<?php +/** + * @group GlobalFunctions + * @covers ::wfAssembleUrl + */ +class WfAssembleUrlTest extends MediaWikiTestCase { + /** + * @dataProvider provideURLParts + */ + public function testWfAssembleUrl( $parts, $output ) { + $partsDump = print_r( $parts, true ); + $this->assertEquals( + $output, + wfAssembleUrl( $parts ), + "Testing $partsDump assembles to $output" + ); + } + + /** + * Provider of URL parts for testing wfAssembleUrl() + * + * @return array + */ + public static function provideURLParts() { + $schemes = array( + '' => array(), + '//' => array( + 'delimiter' => '//', + ), + 'http://' => array( + 'scheme' => 'http', + 'delimiter' => '://', + ), + ); + + $hosts = array( + '' => array(), + 'example.com' => array( + 'host' => 'example.com', + ), + 'example.com:123' => array( + 'host' => 'example.com', + 'port' => 123, + ), + 'id@example.com' => array( + 'user' => 'id', + 'host' => 'example.com', + ), + 'id@example.com:123' => array( + 'user' => 'id', + 'host' => 'example.com', + 'port' => 123, + ), + 'id:key@example.com' => array( + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + ), + 'id:key@example.com:123' => array( + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + 'port' => 123, + ), + ); + + $cases = array(); + foreach ( $schemes as $scheme => $schemeParts ) { + foreach ( $hosts as $host => $hostParts ) { + foreach ( array( '', '/path' ) as $path ) { + foreach ( array( '', 'query' ) as $query ) { + foreach ( array( '', 'fragment' ) as $fragment ) { + $parts = array_merge( + $schemeParts, + $hostParts + ); + $url = $scheme . + $host . + $path; + + if ( $path ) { + $parts['path'] = $path; + } + if ( $query ) { + $parts['query'] = $query; + $url .= '?' . $query; + } + if ( $fragment ) { + $parts['fragment'] = $fragment; + $url .= '#' . $fragment; + } + + $cases[] = array( + $parts, + $url, + ); + } + } + } + } + } + + $complexURL = 'http://id:key@example.org:321' . + '/over/there?name=ferret&foo=bar#nose'; + $cases[] = array( + wfParseUrl( $complexURL ), + $complexURL, + ); + + return $cases; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php b/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php new file mode 100644 index 00000000..166d641f --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBCP47Test.php @@ -0,0 +1,121 @@ +<?php +/** + * @group GlobalFunctions + * @covers ::wfBCP47 + */ +class WfBCP47Test extends MediaWikiTestCase { + /** + * test @see wfBCP47(). + * Please note the BCP explicitly state that language codes are case + * insensitive, there are some exceptions to the rule :) + * This test is used to verify our formatting against all lower and + * all upper cases language code. + * + * @see http://tools.ietf.org/html/bcp47 + * @dataProvider provideLanguageCodes() + */ + public function testBCP47( $code, $expected ) { + $code = strtolower( $code ); + $this->assertEquals( $expected, wfBCP47( $code ), + "Applying BCP47 standard to lower case '$code'" + ); + + $code = strtoupper( $code ); + $this->assertEquals( $expected, wfBCP47( $code ), + "Applying BCP47 standard to upper case '$code'" + ); + } + + /** + * Array format is ($code, $expected) + */ + public static function provideLanguageCodes() { + return array( + // Extracted from BCP47 (list not exhaustive) + # 2.1.1 + array( 'en-ca-x-ca', 'en-CA-x-ca' ), + array( 'sgn-be-fr', 'sgn-BE-FR' ), + array( 'az-latn-x-latn', 'az-Latn-x-latn' ), + # 2.2 + array( 'sr-Latn-RS', 'sr-Latn-RS' ), + array( 'az-arab-ir', 'az-Arab-IR' ), + + # 2.2.5 + array( 'sl-nedis', 'sl-nedis' ), + array( 'de-ch-1996', 'de-CH-1996' ), + + # 2.2.6 + array( + 'en-latn-gb-boont-r-extended-sequence-x-private', + 'en-Latn-GB-boont-r-extended-sequence-x-private' + ), + + // Examples from BCP47 Appendix A + # Simple language subtag: + array( 'DE', 'de' ), + array( 'fR', 'fr' ), + array( 'ja', 'ja' ), + + # Language subtag plus script subtag: + array( 'zh-hans', 'zh-Hans' ), + array( 'sr-cyrl', 'sr-Cyrl' ), + array( 'sr-latn', 'sr-Latn' ), + + # Extended language subtags and their primary language subtag + # counterparts: + array( 'zh-cmn-hans-cn', 'zh-cmn-Hans-CN' ), + array( 'cmn-hans-cn', 'cmn-Hans-CN' ), + array( 'zh-yue-hk', 'zh-yue-HK' ), + array( 'yue-hk', 'yue-HK' ), + + # Language-Script-Region: + array( 'zh-hans-cn', 'zh-Hans-CN' ), + array( 'sr-latn-RS', 'sr-Latn-RS' ), + + # Language-Variant: + array( 'sl-rozaj', 'sl-rozaj' ), + array( 'sl-rozaj-biske', 'sl-rozaj-biske' ), + array( 'sl-nedis', 'sl-nedis' ), + + # Language-Region-Variant: + array( 'de-ch-1901', 'de-CH-1901' ), + array( 'sl-it-nedis', 'sl-IT-nedis' ), + + # Language-Script-Region-Variant: + array( 'hy-latn-it-arevela', 'hy-Latn-IT-arevela' ), + + # Language-Region: + array( 'de-de', 'de-DE' ), + array( 'en-us', 'en-US' ), + array( 'es-419', 'es-419' ), + + # Private use subtags: + array( 'de-ch-x-phonebk', 'de-CH-x-phonebk' ), + array( 'az-arab-x-aze-derbend', 'az-Arab-x-aze-derbend' ), + /** + * Previous test does not reflect the BCP which states: + * az-Arab-x-AZE-derbend + * AZE being private, it should be lower case, hence the test above + * should probably be: + * array( 'az-arab-x-aze-derbend', 'az-Arab-x-AZE-derbend' ), + */ + + # Private use registry values: + array( 'x-whatever', 'x-whatever' ), + array( 'qaa-qaaa-qm-x-southern', 'qaa-Qaaa-QM-x-southern' ), + array( 'de-qaaa', 'de-Qaaa' ), + array( 'sr-latn-qm', 'sr-Latn-QM' ), + array( 'sr-qaaa-rs', 'sr-Qaaa-RS' ), + + # Tags that use extensions + array( 'en-us-u-islamcal', 'en-US-u-islamcal' ), + array( 'zh-cn-a-myext-x-private', 'zh-CN-a-myext-x-private' ), + array( 'en-a-myext-b-another', 'en-a-myext-b-another' ), + + # Invalid: + // de-419-DE + // a-DE + // ar-a-aaa-b-bbb-a-ccc + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php b/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php new file mode 100644 index 00000000..9d55e85c --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBaseConvertTest.php @@ -0,0 +1,195 @@ +<?php +/** + * @group GlobalFunctions + * @covers ::wfBaseConvert + */ +class WfBaseConvertTest extends MediaWikiTestCase { + public static function provideSingleDigitConversions() { + return array( + // 2 3 5 8 10 16 36 + array( '0', '0', '0', '0', '0', '0', '0' ), + array( '1', '1', '1', '1', '1', '1', '1' ), + array( '10', '2', '2', '2', '2', '2', '2' ), + array( '11', '10', '3', '3', '3', '3', '3' ), + array( '100', '11', '4', '4', '4', '4', '4' ), + array( '101', '12', '10', '5', '5', '5', '5' ), + array( '110', '20', '11', '6', '6', '6', '6' ), + array( '111', '21', '12', '7', '7', '7', '7' ), + array( '1000', '22', '13', '10', '8', '8', '8' ), + array( '1001', '100', '14', '11', '9', '9', '9' ), + array( '1010', '101', '20', '12', '10', 'a', 'a' ), + array( '1011', '102', '21', '13', '11', 'b', 'b' ), + array( '1100', '110', '22', '14', '12', 'c', 'c' ), + array( '1101', '111', '23', '15', '13', 'd', 'd' ), + array( '1110', '112', '24', '16', '14', 'e', 'e' ), + array( '1111', '120', '30', '17', '15', 'f', 'f' ), + array( '10000', '121', '31', '20', '16', '10', 'g' ), + array( '10001', '122', '32', '21', '17', '11', 'h' ), + array( '10010', '200', '33', '22', '18', '12', 'i' ), + array( '10011', '201', '34', '23', '19', '13', 'j' ), + array( '10100', '202', '40', '24', '20', '14', 'k' ), + array( '10101', '210', '41', '25', '21', '15', 'l' ), + array( '10110', '211', '42', '26', '22', '16', 'm' ), + array( '10111', '212', '43', '27', '23', '17', 'n' ), + array( '11000', '220', '44', '30', '24', '18', 'o' ), + array( '11001', '221', '100', '31', '25', '19', 'p' ), + array( '11010', '222', '101', '32', '26', '1a', 'q' ), + array( '11011', '1000', '102', '33', '27', '1b', 'r' ), + array( '11100', '1001', '103', '34', '28', '1c', 's' ), + array( '11101', '1002', '104', '35', '29', '1d', 't' ), + array( '11110', '1010', '110', '36', '30', '1e', 'u' ), + array( '11111', '1011', '111', '37', '31', '1f', 'v' ), + array( '100000', '1012', '112', '40', '32', '20', 'w' ), + array( '100001', '1020', '113', '41', '33', '21', 'x' ), + array( '100010', '1021', '114', '42', '34', '22', 'y' ), + array( '100011', '1022', '120', '43', '35', '23', 'z' ) + ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase2( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base2, wfBaseConvert( $base3, '3', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base5, '5', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base8, '8', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base10, '10', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base16, '16', '2' ) ); + $this->assertSame( $base2, wfBaseConvert( $base36, '36', '2' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase3( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base3, wfBaseConvert( $base2, '2', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base5, '5', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base8, '8', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base10, '10', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base16, '16', '3' ) ); + $this->assertSame( $base3, wfBaseConvert( $base36, '36', '3' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase5( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base5, wfBaseConvert( $base2, '2', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base3, '3', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base8, '8', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base10, '10', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base16, '16', '5' ) ); + $this->assertSame( $base5, wfBaseConvert( $base36, '36', '5' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase8( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base8, wfBaseConvert( $base2, '2', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base3, '3', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base5, '5', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base10, '10', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base16, '16', '8' ) ); + $this->assertSame( $base8, wfBaseConvert( $base36, '36', '8' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase10( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base10, wfBaseConvert( $base2, '2', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base3, '3', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base5, '5', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base8, '8', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base16, '16', '10' ) ); + $this->assertSame( $base10, wfBaseConvert( $base36, '36', '10' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase16( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base16, wfBaseConvert( $base2, '2', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base3, '3', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base5, '5', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base8, '8', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base10, '10', '16' ) ); + $this->assertSame( $base16, wfBaseConvert( $base36, '36', '16' ) ); + } + + /** + * @dataProvider provideSingleDigitConversions + */ + public function testDigitToBase36( $base2, $base3, $base5, $base8, $base10, $base16, $base36 ) { + $this->assertSame( $base36, wfBaseConvert( $base2, '2', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base3, '3', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base5, '5', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base8, '8', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base10, '10', '36' ) ); + $this->assertSame( $base36, wfBaseConvert( $base16, '16', '36' ) ); + } + + public function testLargeNumber() { + $this->assertSame( '1100110001111010000000101110100', wfBaseConvert( 'sd89ys', 36, 2 ) ); + $this->assertSame( '11102112120221201101', wfBaseConvert( 'sd89ys', 36, 3 ) ); + $this->assertSame( '12003102232400', wfBaseConvert( 'sd89ys', 36, 5 ) ); + $this->assertSame( '14617200564', wfBaseConvert( 'sd89ys', 36, 8 ) ); + $this->assertSame( '1715274100', wfBaseConvert( 'sd89ys', 36, 10 ) ); + $this->assertSame( '663d0174', wfBaseConvert( 'sd89ys', 36, 16 ) ); + } + + public static function provideNumbers() { + $x = array(); + $chars = '0123456789abcdefghijklmnopqrstuvwxyz'; + for ( $i = 0; $i < 50; $i++ ) { + $base = mt_rand( 2, 36 ); + $len = mt_rand( 10, 100 ); + + $str = ''; + for ( $j = 0; $j < $len; $j++ ) { + $str .= $chars[mt_rand( 0, $base - 1 )]; + } + + $x[] = array( $base, $str ); + } + + return $x; + } + + /** + * @dataProvider provideNumbers + */ + public function testIdentity( $base, $number ) { + $this->assertSame( $number, wfBaseConvert( $number, $base, $base, strlen( $number ) ) ); + } + + public function testInvalid() { + $this->assertFalse( wfBaseConvert( '101', 1, 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 1 ) ); + $this->assertFalse( wfBaseConvert( '101', 37, 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 37 ) ); + $this->assertFalse( wfBaseConvert( 'abcde', 10, 11 ) ); + $this->assertFalse( wfBaseConvert( '12930', 2, 10 ) ); + $this->assertFalse( wfBaseConvert( '101', 'abc', 15 ) ); + $this->assertFalse( wfBaseConvert( '101', 15, 'abc' ) ); + } + + public function testPadding() { + $number = "10101010101"; + $this->assertSame( + strlen( $number ) + 5, + strlen( wfBaseConvert( $number, 2, 2, strlen( $number ) + 5 ) ) + ); + $this->assertSame( + strlen( $number ), + strlen( wfBaseConvert( $number, 2, 2, strlen( $number ) - 5 ) ) + ); + } + + public function testLeadingZero() { + $this->assertSame( '24', wfBaseConvert( '010', 36, 16 ) ); + $this->assertSame( '37d4', wfBaseConvert( '0b10', 36, 16 ) ); + $this->assertSame( 'a734', wfBaseConvert( '0x10', 36, 16 ) ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php new file mode 100644 index 00000000..705730a7 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php @@ -0,0 +1,40 @@ +<?php +/** + * @group GlobalFunctions + * @covers ::wfBaseName + */ +class WfBaseNameTest extends MediaWikiTestCase { + /** + * @dataProvider providePaths + */ + public function testBaseName( $fullpath, $basename ) { + $this->assertEquals( $basename, wfBaseName( $fullpath ), + "wfBaseName('$fullpath') => '$basename'" ); + } + + public static function providePaths() { + return array( + array( '', '' ), + array( '/', '' ), + array( '\\', '' ), + array( '//', '' ), + array( '\\\\', '' ), + array( 'a', 'a' ), + array( 'aaaa', 'aaaa' ), + array( '/a', 'a' ), + array( '\\a', 'a' ), + array( '/aaaa', 'aaaa' ), + array( '\\aaaa', 'aaaa' ), + array( '/aaaa/', 'aaaa' ), + array( '\\aaaa\\', 'aaaa' ), + array( '\\aaaa\\', 'aaaa' ), + array( + '/mnt/upload3/wikipedia/en/thumb/8/8b/' + . 'Zork_Grand_Inquisitor_box_cover.jpg/93px-Zork_Grand_Inquisitor_box_cover.jpg', + '93px-Zork_Grand_Inquisitor_box_cover.jpg' + ), + array( 'C:\\Progra~1\\Wikime~1\\Wikipe~1\\VIEWER.EXE', 'VIEWER.EXE' ), + array( 'Östergötland_coat_of_arms.png', 'Östergötland_coat_of_arms.png' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php new file mode 100644 index 00000000..a69defb3 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfExpandUrlTest.php @@ -0,0 +1,117 @@ +<?php +/** + * @group GlobalFunctions + * @covers ::wfExpandUrl + */ +class WfExpandUrlTest extends MediaWikiTestCase { + /** + * @dataProvider provideExpandableUrls + */ + public function testWfExpandUrl( $fullUrl, $shortUrl, $defaultProto, + $server, $canServer, $httpsMode, $message + ) { + // Fake $wgServer, $wgCanonicalServer and $wgRequest->getProtocol() + $this->setMwGlobals( array( + 'wgServer' => $server, + 'wgCanonicalServer' => $canServer, + 'wgRequest' => new FauxRequest( array(), false, null, $httpsMode ? 'https' : 'http' ) + ) ); + + $this->assertEquals( $fullUrl, wfExpandUrl( $shortUrl, $defaultProto ), $message ); + } + + /** + * Provider of URL examples for testing wfExpandUrl() + * + * @return array + */ + public static function provideExpandableUrls() { + $modes = array( 'http', 'https' ); + $servers = array( + 'http' => 'http://example.com', + 'https' => 'https://example.com', + 'protocol-relative' => '//example.com' + ); + $defaultProtos = array( + 'http' => PROTO_HTTP, + 'https' => PROTO_HTTPS, + 'protocol-relative' => PROTO_RELATIVE, + 'current' => PROTO_CURRENT, + 'canonical' => PROTO_CANONICAL + ); + + $retval = array(); + foreach ( $modes as $mode ) { + $httpsMode = $mode == 'https'; + foreach ( $servers as $serverDesc => $server ) { + foreach ( $modes as $canServerMode ) { + $canServer = "$canServerMode://example2.com"; + foreach ( $defaultProtos as $protoDesc => $defaultProto ) { + $retval[] = array( + 'http://example.com', 'http://example.com', + $defaultProto, $server, $canServer, $httpsMode, + "Testing fully qualified http URLs (no need to expand) " + . "(defaultProto: $protoDesc , wgServer: $server, " + . "wgCanonicalServer: $canServer, current request protocol: $mode )" + ); + $retval[] = array( + 'https://example.com', 'https://example.com', + $defaultProto, $server, $canServer, $httpsMode, + "Testing fully qualified https URLs (no need to expand) " + . "(defaultProto: $protoDesc , wgServer: $server, " + . "wgCanonicalServer: $canServer, current request protocol: $mode )" + ); + # Would be nice to support this, see fixme on wfExpandUrl() + $retval[] = array( + "wiki/FooBar", 'wiki/FooBar', + $defaultProto, $server, $canServer, $httpsMode, + "Test non-expandable relative URLs (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + + // Determine expected protocol + if ( $protoDesc == 'protocol-relative' ) { + $p = ''; + } elseif ( $protoDesc == 'current' ) { + $p = "$mode:"; + } elseif ( $protoDesc == 'canonical' ) { + $p = "$canServerMode:"; + } else { + $p = $protoDesc . ':'; + } + // Determine expected server name + if ( $protoDesc == 'canonical' ) { + $srv = $canServer; + } elseif ( $serverDesc == 'protocol-relative' ) { + $srv = $p . $server; + } else { + $srv = $server; + } + + $retval[] = array( + "$p//wikipedia.org", '//wikipedia.org', + $defaultProto, $server, $canServer, $httpsMode, + "Test protocol-relative URL (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + $retval[] = array( + "$srv/wiki/FooBar", + '/wiki/FooBar', + $defaultProto, + $server, + $canServer, + $httpsMode, + "Testing expanding URL beginning with / (defaultProto: $protoDesc, " + . "wgServer: $server, wgCanonicalServer: $canServer, " + . "current request protocol: $mode )" + ); + } + } + } + } + + return $retval; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php new file mode 100644 index 00000000..bb2b33fe --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php @@ -0,0 +1,46 @@ +<?php + +/** + * @group GlobalFunctions + * @covers ::wfGetCaller + */ +class WfGetCallerTest extends MediaWikiTestCase { + public function testZero() { + $this->assertEquals( __METHOD__, wfGetCaller( 1 ) ); + } + + function callerOne() { + return wfGetCaller(); + } + + public function testOne() { + $this->assertEquals( 'WfGetCallerTest::testOne', self::callerOne() ); + } + + function intermediateFunction( $level = 2, $n = 0 ) { + if ( $n > 0 ) { + return self::intermediateFunction( $level, $n - 1 ); + } + + return wfGetCaller( $level ); + } + + public function testTwo() { + $this->assertEquals( 'WfGetCallerTest::testTwo', self::intermediateFunction() ); + } + + public function testN() { + $this->assertEquals( 'WfGetCallerTest::testN', self::intermediateFunction( 2, 0 ) ); + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( 1, 0 ) + ); + + for ( $i = 0; $i < 10; $i++ ) { + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( $i + 1, $i ) + ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php new file mode 100644 index 00000000..232fa922 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfParseUrlTest.php @@ -0,0 +1,157 @@ +<?php +/** + * Copyright © 2013 Alexandre Emsenhuber + * + * 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 + */ + +/** + * @group GlobalFunctions + * @covers ::wfParseUrl + */ +class WfParseUrlTest extends MediaWikiTestCase { + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( 'wgUrlProtocols', array( + '//', + 'http://', + 'https://', + 'file://', + 'mailto:', + ) ); + } + + /** + * @dataProvider provideURLs + */ + public function testWfParseUrl( $url, $parts ) { + $this->assertEquals( + $parts, + wfParseUrl( $url ) + ); + } + + /** + * Provider of URLs for testing wfParseUrl() + * + * @return array + */ + public static function provideURLs() { + return array( + array( + '//example.org', + array( + 'scheme' => '', + 'delimiter' => '//', + 'host' => 'example.org', + ) + ), + array( + 'http://example.org', + array( + 'scheme' => 'http', + 'delimiter' => '://', + 'host' => 'example.org', + ) + ), + array( + 'https://example.org', + array( + 'scheme' => 'https', + 'delimiter' => '://', + 'host' => 'example.org', + ) + ), + array( + 'http://id:key@example.org:123/path?foo=bar#baz', + array( + 'scheme' => 'http', + 'delimiter' => '://', + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.org', + 'port' => 123, + 'path' => '/path', + 'query' => 'foo=bar', + 'fragment' => 'baz', + ) + ), + array( + 'file://example.org/etc/php.ini', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => 'example.org', + 'path' => '/etc/php.ini', + ) + ), + array( + 'file:///etc/php.ini', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => '', + 'path' => '/etc/php.ini', + ) + ), + array( + 'file:///c:/', + array( + 'scheme' => 'file', + 'delimiter' => '://', + 'host' => '', + 'path' => '/c:/', + ) + ), + array( + 'mailto:id@example.org', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => 'id@example.org', + 'path' => '', + ) + ), + array( + 'mailto:id@example.org?subject=Foo', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => 'id@example.org', + 'path' => '', + 'query' => 'subject=Foo', + ) + ), + array( + 'mailto:?subject=Foo', + array( + 'scheme' => 'mailto', + 'delimiter' => ':', + 'host' => '', + 'path' => '', + 'query' => 'subject=Foo', + ) + ), + array( + 'invalid://test/', + false + ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php new file mode 100644 index 00000000..1faad52a --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php @@ -0,0 +1,93 @@ +<?php + +/** + * @group GlobalFunctions + * @covers ::wfRemoveDotSegments + */ +class WfRemoveDotSegmentsTest extends MediaWikiTestCase { + /** + * @dataProvider providePaths + */ + public function testWfRemoveDotSegments( $inputPath, $outputPath ) { + $this->assertEquals( + $outputPath, + wfRemoveDotSegments( $inputPath ), + "Testing $inputPath expands to $outputPath" + ); + } + + /** + * Provider of URL paths for testing wfRemoveDotSegments() + * + * @return array + */ + public static function providePaths() { + return array( + array( '/a/b/c/./../../g', '/a/g' ), + array( 'mid/content=5/../6', 'mid/6' ), + array( '/a//../b', '/a/b' ), + array( '/.../a', '/.../a' ), + array( '.../a', '.../a' ), + array( '', '' ), + array( '/', '/' ), + array( '//', '//' ), + array( '.', '' ), + array( '..', '' ), + array( '...', '...' ), + array( '/.', '/' ), + array( '/..', '/' ), + array( './', '' ), + array( '../', '' ), + array( './a', 'a' ), + array( '../a', 'a' ), + array( '../../a', 'a' ), + array( '.././a', 'a' ), + array( './../a', 'a' ), + array( '././a', 'a' ), + array( '../../', '' ), + array( '.././', '' ), + array( './../', '' ), + array( '././', '' ), + array( '../..', '' ), + array( '../.', '' ), + array( './..', '' ), + array( './.', '' ), + array( '/../../a', '/a' ), + array( '/.././a', '/a' ), + array( '/./../a', '/a' ), + array( '/././a', '/a' ), + array( '/../../', '/' ), + array( '/.././', '/' ), + array( '/./../', '/' ), + array( '/././', '/' ), + array( '/../..', '/' ), + array( '/../.', '/' ), + array( '/./..', '/' ), + array( '/./.', '/' ), + array( 'b/../../a', '/a' ), + array( 'b/.././a', '/a' ), + array( 'b/./../a', '/a' ), + array( 'b/././a', 'b/a' ), + array( 'b/../../', '/' ), + array( 'b/.././', '/' ), + array( 'b/./../', '/' ), + array( 'b/././', 'b/' ), + array( 'b/../..', '/' ), + array( 'b/../.', '/' ), + array( 'b/./..', '/' ), + array( 'b/./.', 'b/' ), + array( '/b/../../a', '/a' ), + array( '/b/.././a', '/a' ), + array( '/b/./../a', '/a' ), + array( '/b/././a', '/b/a' ), + array( '/b/../../', '/' ), + array( '/b/.././', '/' ), + array( '/b/./../', '/' ), + array( '/b/././', '/b/' ), + array( '/b/../..', '/' ), + array( '/b/../.', '/' ), + array( '/b/./..', '/' ), + array( '/b/./.', '/b/' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php new file mode 100644 index 00000000..fcd26f54 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php @@ -0,0 +1,20 @@ +<?php + +/** + * @group GlobalFunctions + * @covers ::wfShellExec + */ +class WfShellExecTest extends MediaWikiTestCase { + public function testBug67870() { + $command = wfIsWindows() + // 333 = 331 + CRLF + ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) ) + : 'printf "%-333333s" "*"'; + + // Test several times because it involves a race condition that may randomly succeed or fail + for ( $i = 0; $i < 10; $i++ ) { + $output = wfShellExec( $command ); + $this->assertEquals( 333333, strlen( $output ) ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php new file mode 100644 index 00000000..67284d27 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php @@ -0,0 +1,31 @@ +<?php + +/** + * @group GlobalFunctions + * @covers ::wfShorthandToInteger + */ +class WfShorthandToIntegerTest extends MediaWikiTestCase { + /** + * @dataProvider provideABunchOfShorthands + */ + public function testWfShorthandToInteger( $input, $output, $description ) { + $this->assertEquals( + wfShorthandToInteger( $input ), + $output, + $description + ); + } + + public static function provideABunchOfShorthands() { + return array( + array( '', -1, 'Empty string' ), + array( ' ', -1, 'String of spaces' ), + array( '1G', 1024 * 1024 * 1024, 'One gig uppercased' ), + array( '1g', 1024 * 1024 * 1024, 'One gig lowercased' ), + array( '1M', 1024 * 1024, 'One meg uppercased' ), + array( '1m', 1024 * 1024, 'One meg lowercased' ), + array( '1K', 1024, 'One kb uppercased' ), + array( '1k', 1024, 'One kb lowercased' ), + ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php new file mode 100644 index 00000000..bea496c4 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php @@ -0,0 +1,196 @@ +<?php + +/** + * @group GlobalFunctions + * @covers ::wfTimestamp + */ +class WfTimestampTest extends MediaWikiTestCase { + /** + * @dataProvider provideNormalTimestamps + */ + public function testNormalTimestamps( $input, $format, $output, $desc ) { + $this->assertEquals( $output, wfTimestamp( $format, $input ), $desc ); + } + + public static function provideNormalTimestamps() { + $t = gmmktime( 12, 34, 56, 1, 15, 2001 ); + + return array( + // TS_UNIX + array( $t, TS_MW, '20010115123456', 'TS_UNIX to TS_MW' ), + array( -30281104, TS_MW, '19690115123456', 'Negative TS_UNIX to TS_MW' ), + array( $t, TS_UNIX, 979562096, 'TS_UNIX to TS_UNIX' ), + array( $t, TS_DB, '2001-01-15 12:34:56', 'TS_UNIX to TS_DB' ), + + array( $t, TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_ISO_8601_BASIC to TS_DB' ), + + // TS_MW + array( '20010115123456', TS_MW, '20010115123456', 'TS_MW to TS_MW' ), + array( '20010115123456', TS_UNIX, 979562096, 'TS_MW to TS_UNIX' ), + array( '20010115123456', TS_DB, '2001-01-15 12:34:56', 'TS_MW to TS_DB' ), + array( '20010115123456', TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_MW to TS_ISO_8601_BASIC' ), + + // TS_DB + array( '2001-01-15 12:34:56', TS_MW, '20010115123456', 'TS_DB to TS_MW' ), + array( '2001-01-15 12:34:56', TS_UNIX, 979562096, 'TS_DB to TS_UNIX' ), + array( '2001-01-15 12:34:56', TS_DB, '2001-01-15 12:34:56', 'TS_DB to TS_DB' ), + array( + '2001-01-15 12:34:56', + TS_ISO_8601_BASIC, + '20010115T123456Z', + 'TS_DB to TS_ISO_8601_BASIC' + ), + + # rfc2822 section 3.3 + array( '20010115123456', TS_RFC2822, 'Mon, 15 Jan 2001 12:34:56 GMT', 'TS_MW to TS_RFC2822' ), + array( 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ), + array( + ' Mon, 15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 with leading space to TS_MW' + ), + array( + '15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 without optional day-of-week to TS_MW' + ), + + # FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space + # obs-FWS = 1*WSP *(CRLF 1*WSP) ; Section 4.2 + array( 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ), + + # WSP = SP / HTAB ; rfc2234 + array( + "Mon, 15 Jan\x092001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB to TS_MW' + ), + array( + "Mon, 15 Jan\x09 \x09 2001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB and SP to TS_MW' + ), + array( + 'Sun, 6 Nov 94 08:49:37 GMT', + TS_MW, + '19941106084937', + 'TS_RFC2822 with obsolete year to TS_MW' + ), + ); + } + + /** + * This test checks wfTimestamp() with values outside. + * It needs PHP 64 bits or PHP > 5.1. + * See r74778 and bug 25451 + * @dataProvider provideOldTimestamps + */ + public function testOldTimestamps( $input, $outputType, $output, $message ) { + $timestamp = wfTimestamp( $outputType, $input ); + if ( substr( $output, 0, 1 ) === '/' ) { + // Bug 64946: Day of the week calculations for very old + // timestamps varies from system to system. + $this->assertRegExp( $output, $timestamp, $message ); + } else { + $this->assertEquals( $output, $timestamp, $message ); + } + } + + public static function provideOldTimestamps() { + return array( + array( + '19011213204554', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:54 GMT', + 'Earliest time according to PHP documentation' + ), + array( '20380119031407', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:07 GMT', 'Latest 32 bit time' ), + array( '19011213204552', TS_UNIX, '-2147483648', 'Earliest 32 bit unix time' ), + array( '20380119031407', TS_UNIX, '2147483647', 'Latest 32 bit unix time' ), + array( '19011213204552', TS_RFC2822, 'Fri, 13 Dec 1901 20:45:52 GMT', 'Earliest 32 bit time' ), + array( + '19011213204551', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:51 GMT', 'Earliest 32 bit time - 1' + ), + array( '20380119031408', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:08 GMT', 'Latest 32 bit time + 1' ), + array( '19011212000000', TS_MW, '19011212000000', 'Convert to itself r74778#c10645' ), + array( '19011213204551', TS_UNIX, '-2147483649', 'Earliest 32 bit unix time - 1' ), + array( '20380119031408', TS_UNIX, '2147483648', 'Latest 32 bit unix time + 1' ), + array( '-2147483649', TS_MW, '19011213204551', '1901 negative unix time to MediaWiki' ), + array( '-5331871504', TS_MW, '18010115123456', '1801 negative unix time to MediaWiki' ), + array( + '0117-08-09 12:34:56', + TS_RFC2822, + '/, 09 Aug 0117 12:34:56 GMT$/', + 'Death of Roman Emperor [[Trajan]]' + ), + + /* @todo FIXME: 00 to 101 years are taken as being in [1970-2069] */ + array( '-58979923200', TS_RFC2822, '/, 01 Jan 0101 00:00:00 GMT$/', '1/1/101' ), + array( '-62135596800', TS_RFC2822, 'Mon, 01 Jan 0001 00:00:00 GMT', 'Year 1' ), + + /* It is not clear if we should generate a year 0 or not + * We are completely off RFC2822 requirement of year being + * 1900 or later. + */ + array( + '-62142076800', + TS_RFC2822, + 'Wed, 18 Oct 0000 00:00:00 GMT', + 'ISO 8601:2004 [[year 0]], also called [[1 BC]]' + ), + ); + } + + /** + * The Resource Loader uses wfTimestamp() to convert timestamps + * from If-Modified-Since header. Thus it must be able to parse all + * rfc2616 date formats + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1 + * @dataProvider provideHttpDates + */ + public function testHttpDate( $input, $output, $desc ) { + $this->assertEquals( $output, wfTimestamp( TS_MW, $input ), $desc ); + } + + public static function provideHttpDates() { + return array( + array( 'Sun, 06 Nov 1994 08:49:37 GMT', '19941106084937', 'RFC 822 date' ), + array( 'Sunday, 06-Nov-94 08:49:37 GMT', '19941106084937', 'RFC 850 date' ), + array( 'Sun Nov 6 08:49:37 1994', '19941106084937', "ANSI C's asctime() format" ), + // See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html and r77171 + array( + 'Mon, 22 Nov 2010 14:12:42 GMT; length=52626', + '20101122141242', + 'Netscape extension to HTTP/1.0' + ), + ); + } + + /** + * There are a number of assumptions in our codebase where wfTimestamp() + * should give the current date but it is not given a 0 there. See r71751 CR + */ + public function testTimestampParameter() { + $now = wfTimestamp( TS_UNIX ); + // We check that wfTimestamp doesn't return false (error) and use a LessThan assert + // for the cases where the test is run in a second boundary. + + $zero = wfTimestamp( TS_UNIX, 0 ); + $this->assertNotEquals( false, $zero ); + $this->assertLessThan( 5, $zero - $now ); + + $empty = wfTimestamp( TS_UNIX, '' ); + $this->assertNotEquals( false, $empty ); + $this->assertLessThan( 5, $empty - $now ); + + $null = wfTimestamp( TS_UNIX, null ); + $this->assertNotEquals( false, $null ); + $this->assertLessThan( 5, $null - $now ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php new file mode 100644 index 00000000..d11668b7 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php @@ -0,0 +1,124 @@ +<?php + +/** + * The function only need a string parameter and might react to IIS7.0 + * + * @group GlobalFunctions + * @covers ::wfUrlencode + */ +class WfUrlencodeTest extends MediaWikiTestCase { + #### TESTS ############################################################## + + /** + * @dataProvider provideURLS + */ + public function testEncodingUrlWith( $input, $expected ) { + $this->verifyEncodingFor( 'Apache', $input, $expected ); + } + + /** + * @dataProvider provideURLS + */ + public function testEncodingUrlWithMicrosoftIis7( $input, $expected ) { + $this->verifyEncodingFor( 'Microsoft-IIS/7', $input, $expected ); + } + + #### HELPERS ############################################################# + + /** + * Internal helper that actually run the test. + * Called by the public methods testEncodingUrlWith...() + * + */ + private function verifyEncodingFor( $server, $input, $expectations ) { + $expected = $this->extractExpect( $server, $expectations ); + + // save up global + $old = isset( $_SERVER['SERVER_SOFTWARE'] ) + ? $_SERVER['SERVER_SOFTWARE'] + : null; + $_SERVER['SERVER_SOFTWARE'] = $server; + wfUrlencode( null ); + + // do the requested test + $this->assertEquals( + $expected, + wfUrlencode( $input ), + "Encoding '$input' for server '$server' should be '$expected'" + ); + + // restore global + if ( $old === null ) { + unset( $_SERVER['SERVER_SOFTWARE'] ); + } else { + $_SERVER['SERVER_SOFTWARE'] = $old; + } + wfUrlencode( null ); + } + + /** + * Interprets the provider array. Return expected value depending + * the HTTP server name. + */ + private function extractExpect( $server, $expectations ) { + if ( is_string( $expectations ) ) { + return $expectations; + } elseif ( is_array( $expectations ) ) { + if ( !array_key_exists( $server, $expectations ) ) { + throw new MWException( __METHOD__ . " expectation does not have any " + . "value for server name $server. Check the provider array.\n" ); + } else { + return $expectations[$server]; + } + } else { + throw new MWException( __METHOD__ . " given invalid expectation for " + . "'$server'. Should be a string or an array( <http server name> => <string> ).\n" ); + } + } + + #### PROVIDERS ########################################################### + + /** + * Format is either: + * array( 'input', 'expected' ); + * Or: + * array( 'input', + * array( 'Apache', 'expected' ), + * array( 'Microsoft-IIS/7', 'expected' ), + * ), + * If you want to add other HTTP server name, you will have to add a new + * testing method much like the testEncodingUrlWith() method above. + */ + public static function provideURLS() { + return array( + ### RFC 1738 chars + // + is not safe + array( '+', '%2B' ), + // & and = not safe in queries + array( '&', '%26' ), + array( '=', '%3D' ), + + array( ':', array( + 'Apache' => ':', + 'Microsoft-IIS/7' => '%3A', + ) ), + + // remaining chars do not need encoding + array( + ';@$-_.!*', + ';@$-_.!*', + ), + + ### Other tests + // slash remain unchanged. %2F seems to break things + array( '/', '/' ), + + // Other 'funnies' chars + array( '[]', '%5B%5D' ), + array( '<>', '%3C%3E' ), + + // Apostrophe is encoded + array( '\'', '%27' ), + ); + } +} diff --git a/tests/phpunit/includes/HooksTest.php b/tests/phpunit/includes/HooksTest.php new file mode 100644 index 00000000..74d4b091 --- /dev/null +++ b/tests/phpunit/includes/HooksTest.php @@ -0,0 +1,202 @@ +<?php + +class HooksTest extends MediaWikiTestCase { + + function setUp() { + global $wgHooks; + parent::setUp(); + Hooks::clear( 'MediaWikiHooksTest001' ); + unset( $wgHooks['MediaWikiHooksTest001'] ); + } + + public static function provideHooks() { + $i = new NothingClass(); + + return array( + array( + 'Object and method', + array( $i, 'someNonStatic' ), + 'changed-nonstatic', + 'changed-nonstatic' + ), + array( 'Object and no method', array( $i ), 'changed-onevent', 'original' ), + array( + 'Object and method with data', + array( $i, 'someNonStaticWithData', 'data' ), + 'data', + 'original' + ), + array( 'Object and static method', array( $i, 'someStatic' ), 'changed-static', 'original' ), + array( + 'Class::method static call', + array( 'NothingClass::someStatic' ), + 'changed-static', + 'original' + ), + array( 'Global function', array( 'NothingFunction' ), 'changed-func', 'original' ), + array( 'Global function with data', array( 'NothingFunctionData', 'data' ), 'data', 'original' ), + array( 'Closure', array( function ( &$foo, $bar ) { + $foo = 'changed-closure'; + + return true; + } ), 'changed-closure', 'original' ), + array( 'Closure with data', array( function ( $data, &$foo, $bar ) { + $foo = $data; + + return true; + }, 'data' ), 'data', 'original' ) + ); + } + + /** + * @dataProvider provideHooks + * @covers ::wfRunHooks + */ + public function testOldStyleHooks( $msg, array $hook, $expectedFoo, $expectedBar ) { + global $wgHooks; + $foo = $bar = 'original'; + + $wgHooks['MediaWikiHooksTest001'][] = $hook; + wfRunHooks( 'MediaWikiHooksTest001', array( &$foo, &$bar ) ); + + $this->assertSame( $expectedFoo, $foo, $msg ); + $this->assertSame( $expectedBar, $bar, $msg ); + } + + /** + * @dataProvider provideHooks + * @covers Hooks::register + * @covers Hooks::run + */ + public function testNewStyleHooks( $msg, $hook, $expectedFoo, $expectedBar ) { + $foo = $bar = 'original'; + + Hooks::register( 'MediaWikiHooksTest001', $hook ); + Hooks::run( 'MediaWikiHooksTest001', array( &$foo, &$bar ) ); + + $this->assertSame( $expectedFoo, $foo, $msg ); + $this->assertSame( $expectedBar, $bar, $msg ); + } + + /** + * @covers Hooks::isRegistered + * @covers Hooks::register + * @covers Hooks::getHandlers + * @covers Hooks::run + */ + public function testNewStyleHookInteraction() { + global $wgHooks; + + $a = new NothingClass(); + $b = new NothingClass(); + + $wgHooks['MediaWikiHooksTest001'][] = $a; + $this->assertTrue( + Hooks::isRegistered( 'MediaWikiHooksTest001' ), + 'Hook registered via $wgHooks should be noticed by Hooks::isRegistered' + ); + + Hooks::register( 'MediaWikiHooksTest001', $b ); + $this->assertEquals( + 2, + count( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ), + 'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register' + ); + + $foo = 'quux'; + $bar = 'qaax'; + + Hooks::run( 'MediaWikiHooksTest001', array( &$foo, &$bar ) ); + $this->assertEquals( + 1, + $a->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + $this->assertEquals( + 1, + $b->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + } + + /** + * @expectedException MWException + * @covers Hooks::run + */ + public function testUncallableFunction() { + Hooks::register( 'MediaWikiHooksTest001', 'ThisFunctionDoesntExist' ); + Hooks::run( 'MediaWikiHooksTest001', array() ); + } + + /** + * @covers Hooks::run + */ + public function testFalseReturn() { + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + return false; + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + + return true; + } ); + $foo = 'original'; + Hooks::run( 'MediaWikiHooksTest001', array( &$foo ) ); + $this->assertSame( 'original', $foo, 'Hooks continued processing after a false return.' ); + } + + /** + * @expectedException FatalError + * @covers Hooks::run + */ + public function testFatalError() { + Hooks::register( 'MediaWikiHooksTest001', function () { + return 'test'; + } ); + Hooks::run( 'MediaWikiHooksTest001', array() ); + } +} + +function NothingFunction( &$foo, &$bar ) { + $foo = 'changed-func'; + + return true; +} + +function NothingFunctionData( $data, &$foo, &$bar ) { + $foo = $data; + + return true; +} + +class NothingClass { + public $calls = 0; + + public static function someStatic( &$foo, &$bar ) { + $foo = 'changed-static'; + + return true; + } + + public function someNonStatic( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-nonstatic'; + $bar = 'changed-nonstatic'; + + return true; + } + + public function onMediaWikiHooksTest001( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-onevent'; + + return true; + } + + public function someNonStaticWithData( $data, &$foo, &$bar ) { + $this->calls++; + $foo = $data; + + return true; + } +} diff --git a/tests/phpunit/includes/HtmlFormatterTest.php b/tests/phpunit/includes/HtmlFormatterTest.php new file mode 100644 index 00000000..9dbfa452 --- /dev/null +++ b/tests/phpunit/includes/HtmlFormatterTest.php @@ -0,0 +1,127 @@ +<?php + +/** + * @group HtmlFormatter + */ +class HtmlFormatterTest extends MediaWikiTestCase { + /** + * @dataProvider getHtmlData + * + * @param string $input + * @param string $expectedText + * @param array $expectedRemoved + * @param callable|bool $callback + */ + public function testTransform( $input, $expectedText, + $expectedRemoved = array(), $callback = false + ) { + $input = self::normalize( $input ); + $formatter = new HtmlFormatter( HtmlFormatter::wrapHTML( $input ) ); + if ( $callback ) { + $callback( $formatter ); + } + $removedElements = $formatter->filterContent(); + $html = $formatter->getText(); + $removed = array(); + foreach ( $removedElements as $removedElement ) { + $removed[] = self::normalize( $formatter->getText( $removedElement ) ); + } + $expectedRemoved = array_map( 'self::normalize', $expectedRemoved ); + + $this->assertValidHtmlSnippet( $html ); + $this->assertEquals( self::normalize( $expectedText ), self::normalize( $html ) ); + $this->assertEquals( asort( $expectedRemoved ), asort( $removed ) ); + } + + private static function normalize( $s ) { + return str_replace( "\n", '', + str_replace( "\r", '', $s ) // "yay" to Windows! + ); + } + + public function getHtmlData() { + $removeImages = function ( HtmlFormatter $f ) { + $f->setRemoveMedia(); + }; + $removeTags = function ( HtmlFormatter $f ) { + $f->remove( array( 'table', '.foo', '#bar', 'div.baz' ) ); + }; + $flattenSomeStuff = function ( HtmlFormatter $f ) { + $f->flatten( array( 's', 'div' ) ); + }; + $flattenEverything = function ( HtmlFormatter $f ) { + $f->flattenAllTags(); + }; + return array( + // remove images if asked + array( + '<img src="/foo/bar.jpg" alt="Blah"/>', + '', + array( '<img src="/foo/bar.jpg" alt="Blah">' ), + $removeImages, + ), + // basic tag removal + array( + // @codingStandardsIgnoreStart Ignore long line warnings. + '<table><tr><td>foo</td></tr></table><div class="foo">foo</div><div class="foo quux">foo</div><span id="bar">bar</span> +<strong class="foo" id="bar">foobar</strong><div class="notfoo">test</div><div class="baz"/> +<span class="baz">baz</span>', + // @codingStandardsIgnoreEnd + '<div class="notfoo">test</div> +<span class="baz">baz</span>', + array( + '<table><tr><td>foo</td></tr></table>', + '<div class="foo">foo</div>', + '<div class="foo quux">foo</div>', + '<span id="bar">bar</span>', + '<strong class="foo" id="bar">foobar</strong>', + '<div class="baz"/>', + ), + $removeTags, + ), + // don't flatten tags that start like chosen ones + array( + '<div><s>foo</s> <span>bar</span></div>', + 'foo <span>bar</span>', + array(), + $flattenSomeStuff, + ), + // total flattening + array( + '<div style="foo">bar<sup>2</sup></div>', + 'bar2', + array(), + $flattenEverything, + ), + // UTF-8 preservation and security + array( + '<span title="" \' &"><Тест!></span> &<&&&&', + '<span title="" \' &"><Тест!></span> &<&&&&', + array(), + $removeTags, // Have some rules to trigger a DOM parse + ), + // https://bugzilla.wikimedia.org/show_bug.cgi?id=53086 + array( + 'Foo<sup id="cite_ref-1" class="reference"><a href="#cite_note-1">[1]</a></sup>' + . ' <a href="/wiki/Bar" title="Bar" class="mw-redirect">Bar</a>', + 'Foo<sup id="cite_ref-1" class="reference"><a href="#cite_note-1">[1]</a></sup>' + . ' <a href="/wiki/Bar" title="Bar" class="mw-redirect">Bar</a>', + ), + ); + } + + public function testQuickProcessing() { + $f = new MockHtmlFormatter( 'foo' ); + $f->filterContent(); + $this->assertFalse( $f->hasDoc, 'HtmlFormatter should not needlessly parse HTML' ); + } +} + +class MockHtmlFormatter extends HtmlFormatter { + public $hasDoc = false; + + public function getDoc() { + $this->hasDoc = true; + return parent::getDoc(); + } +} diff --git a/tests/phpunit/includes/HtmlTest.php b/tests/phpunit/includes/HtmlTest.php new file mode 100644 index 00000000..a8829cd8 --- /dev/null +++ b/tests/phpunit/includes/HtmlTest.php @@ -0,0 +1,773 @@ +<?php +/** tests for includes/Html.php */ + +class HtmlTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $langCode = 'en'; + $langObj = Language::factory( $langCode ); + + // Hardcode namespaces during test runs, + // so that html output based on existing namespaces + // can be properly evaluated. + $langObj->setNamespaces( array( + -2 => 'Media', + -1 => 'Special', + 0 => '', + 1 => 'Talk', + 2 => 'User', + 3 => 'User_talk', + 4 => 'MyWiki', + 5 => 'MyWiki_Talk', + 6 => 'File', + 7 => 'File_talk', + 8 => 'MediaWiki', + 9 => 'MediaWiki_talk', + 10 => 'Template', + 11 => 'Template_talk', + 14 => 'Category', + 15 => 'Category_talk', + 100 => 'Custom', + 101 => 'Custom_talk', + ) ); + + $this->setMwGlobals( array( + 'wgLanguageCode' => $langCode, + 'wgContLang' => $langObj, + 'wgLang' => $langObj, + 'wgWellFormedXml' => false, + ) ); + } + + /** + * @covers Html::element + */ + public function testElementBasics() { + $this->assertEquals( + '<img>', + Html::element( 'img', null, '' ), + 'No close tag for short-tag elements' + ); + + $this->assertEquals( + '<element></element>', + Html::element( 'element', null, null ), + 'Close tag for empty element (null, null)' + ); + + $this->assertEquals( + '<element></element>', + Html::element( 'element', array(), '' ), + 'Close tag for empty element (array, string)' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + '<img />', + Html::element( 'img', null, '' ), + 'Self-closing tag for short-tag elements (wgWellFormedXml = true)' + ); + } + + public function dataXmlMimeType() { + return array( + // ( $mimetype, $isXmlMimeType ) + # HTML is not an XML MimeType + array( 'text/html', false ), + # XML is an XML MimeType + array( 'text/xml', true ), + array( 'application/xml', true ), + # XHTML is an XML MimeType + array( 'application/xhtml+xml', true ), + # Make sure other +xml MimeTypes are supported + # SVG is another random MimeType even though we don't use it + array( 'image/svg+xml', true ), + # Complete random other MimeTypes are not XML + array( 'text/plain', false ), + ); + } + + /** + * @dataProvider dataXmlMimeType + * @covers Html::isXmlMimeType + */ + public function testXmlMimeType( $mimetype, $isXmlMimeType ) { + $this->assertEquals( $isXmlMimeType, Html::isXmlMimeType( $mimetype ) ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesSkipsNullAndFalse() { + + ### EMPTY ######## + $this->assertEmpty( + Html::expandAttributes( array( 'foo' => null ) ), + 'skip keys with null value' + ); + $this->assertEmpty( + Html::expandAttributes( array( 'foo' => false ) ), + 'skip keys with false value' + ); + $this->assertEquals( + ' foo=""', + Html::expandAttributes( array( 'foo' => '' ) ), + 'keep keys with an empty string' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForBooleans() { + $this->assertEquals( + '', + Html::expandAttributes( array( 'selected' => false ) ), + 'Boolean attributes do not generates output when value is false' + ); + $this->assertEquals( + '', + Html::expandAttributes( array( 'selected' => null ) ), + 'Boolean attributes do not generates output when value is null' + ); + + $this->assertEquals( + ' selected', + Html::expandAttributes( array( 'selected' => true ) ), + 'Boolean attributes have no value when value is true' + ); + $this->assertEquals( + ' selected', + Html::expandAttributes( array( 'selected' ) ), + 'Boolean attributes have no value when value is true (passed as numerical array)' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + ' selected=""', + Html::expandAttributes( array( 'selected' => true ) ), + 'Boolean attributes have empty string value when value is true (wgWellFormedXml)' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForNumbers() { + $this->assertEquals( + ' value=1', + Html::expandAttributes( array( 'value' => 1 ) ), + 'Integer value is cast to a string' + ); + $this->assertEquals( + ' value=1.1', + Html::expandAttributes( array( 'value' => 1.1 ) ), + 'Float value is cast to a string' + ); + } + + /** + * @covers HTML::expandAttributes + */ + public function testExpandAttributesForObjects() { + $this->assertEquals( + ' value=stringValue', + Html::expandAttributes( array( 'value' => new HtmlTestValue() ) ), + 'Object value is converted to a string' + ); + } + + /** + * Test for Html::expandAttributes() + * Please note it output a string prefixed with a space! + * @covers Html::expandAttributes + */ + public function testExpandAttributesVariousExpansions() { + ### NOT EMPTY #### + $this->assertEquals( + ' empty_string=""', + Html::expandAttributes( array( 'empty_string' => '' ) ), + 'Empty string is always quoted' + ); + $this->assertEquals( + ' key=value', + Html::expandAttributes( array( 'key' => 'value' ) ), + 'Simple string value needs no quotes' + ); + $this->assertEquals( + ' one=1', + Html::expandAttributes( array( 'one' => 1 ) ), + 'Number 1 value needs no quotes' + ); + $this->assertEquals( + ' zero=0', + Html::expandAttributes( array( 'zero' => 0 ) ), + 'Number 0 value needs no quotes' + ); + + $this->setMwGlobals( 'wgWellFormedXml', true ); + + $this->assertEquals( + ' empty_string=""', + Html::expandAttributes( array( 'empty_string' => '' ) ), + 'Attribute values are always quoted (wgWellFormedXml): Empty string' + ); + $this->assertEquals( + ' key="value"', + Html::expandAttributes( array( 'key' => 'value' ) ), + 'Attribute values are always quoted (wgWellFormedXml): Simple string' + ); + $this->assertEquals( + ' one="1"', + Html::expandAttributes( array( 'one' => 1 ) ), + 'Attribute values are always quoted (wgWellFormedXml): Number 1' + ); + $this->assertEquals( + ' zero="0"', + Html::expandAttributes( array( 'zero' => 0 ) ), + 'Attribute values are always quoted (wgWellFormedXml): Number 0' + ); + } + + /** + * Html::expandAttributes has special features for HTML + * attributes that use space separated lists and also + * allows arrays to be used as values. + * @covers Html::expandAttributes + */ + public function testExpandAttributesListValueAttributes() { + ### STRING VALUES + $this->assertEquals( + ' class="redundant spaces here"', + Html::expandAttributes( array( 'class' => ' redundant spaces here ' ) ), + 'Normalization should strip redundant spaces' + ); + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => 'foo bar foo bar bar' ) ), + 'Normalization should remove duplicates in string-lists' + ); + ### "EMPTY" ARRAY VALUES + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array() ) ), + 'Value with an empty array' + ); + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array( null, '', ' ', ' ' ) ) ), + 'Array with null, empty string and spaces' + ); + ### NON-EMPTY ARRAY VALUES + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => array( + 'foo', + 'bar', + 'foo', + 'bar', + 'bar', + ) ) ), + 'Normalization should remove duplicates in the array' + ); + $this->assertEquals( + ' class="foo bar"', + Html::expandAttributes( array( 'class' => array( + 'foo bar', + 'bar foo', + 'foo', + 'bar bar', + ) ) ), + 'Normalization should remove duplicates in string-lists in the array' + ); + } + + /** + * Test feature added by r96188, let pass attributes values as + * a PHP array. Restricted to class,rel, accesskey. + * @covers Html::expandAttributes + */ + public function testExpandAttributesSpaceSeparatedAttributesWithBoolean() { + $this->assertEquals( + ' class="booltrue one"', + Html::expandAttributes( array( 'class' => array( + 'booltrue' => true, + 'one' => 1, + + # Method use isset() internally, make sure we do discard + # attributes values which have been assigned well known values + 'emptystring' => '', + 'boolfalse' => false, + 'zero' => 0, + 'null' => null, + ) ) ) + ); + } + + /** + * How do we handle duplicate keys in HTML attributes expansion? + * We could pass a "class" the values: 'GREEN' and array( 'GREEN' => false ) + * The later will take precedence. + * + * Feature added by r96188 + * @covers Html::expandAttributes + */ + public function testValueIsAuthoritativeInSpaceSeparatedAttributesArrays() { + $this->assertEquals( + ' class=""', + Html::expandAttributes( array( 'class' => array( + 'GREEN', + 'GREEN' => false, + 'GREEN', + ) ) ) + ); + } + + /** + * @covers Html::expandAttributes + * @expectedException MWException + */ + public function testExpandAttributes_ArrayOnNonListValueAttribute_ThrowsException() { + // Real-life test case found in the Popups extension (see Gerrit cf0fd64), + // when used with an outdated BetaFeatures extension (see Gerrit deda1e7) + Html::expandAttributes( array( + 'src' => array( + 'ltr' => 'ltr.svg', + 'rtl' => 'rtl.svg' + ) + ) ); + } + + /** + * @covers Html::namespaceSelector + */ + public function testNamespaceSelector() { + $this->assertEquals( + '<select id=namespace name=namespace>' . "\n" . + '<option value=0>(Main)</option>' . "\n" . + '<option value=1>Talk</option>' . "\n" . + '<option value=2>User</option>' . "\n" . + '<option value=3>User talk</option>' . "\n" . + '<option value=4>MyWiki</option>' . "\n" . + '<option value=5>MyWiki Talk</option>' . "\n" . + '<option value=6>File</option>' . "\n" . + '<option value=7>File talk</option>' . "\n" . + '<option value=8>MediaWiki</option>' . "\n" . + '<option value=9>MediaWiki talk</option>' . "\n" . + '<option value=10>Template</option>' . "\n" . + '<option value=11>Template talk</option>' . "\n" . + '<option value=14>Category</option>' . "\n" . + '<option value=15>Category talk</option>' . "\n" . + '<option value=100>Custom</option>' . "\n" . + '<option value=101>Custom talk</option>' . "\n" . + '</select>', + Html::namespaceSelector(), + 'Basic namespace selector without custom options' + ); + + $this->assertEquals( + '<label for=mw-test-namespace>Select a namespace:</label> ' . + '<select id=mw-test-namespace name=wpNamespace>' . "\n" . + '<option value=all>all</option>' . "\n" . + '<option value=0>(Main)</option>' . "\n" . + '<option value=1>Talk</option>' . "\n" . + '<option value=2 selected>User</option>' . "\n" . + '<option value=3>User talk</option>' . "\n" . + '<option value=4>MyWiki</option>' . "\n" . + '<option value=5>MyWiki Talk</option>' . "\n" . + '<option value=6>File</option>' . "\n" . + '<option value=7>File talk</option>' . "\n" . + '<option value=8>MediaWiki</option>' . "\n" . + '<option value=9>MediaWiki talk</option>' . "\n" . + '<option value=10>Template</option>' . "\n" . + '<option value=11>Template talk</option>' . "\n" . + '<option value=14>Category</option>' . "\n" . + '<option value=15>Category talk</option>' . "\n" . + '<option value=100>Custom</option>' . "\n" . + '<option value=101>Custom talk</option>' . "\n" . + '</select>', + Html::namespaceSelector( + array( 'selected' => '2', 'all' => 'all', 'label' => 'Select a namespace:' ), + array( 'name' => 'wpNamespace', 'id' => 'mw-test-namespace' ) + ), + 'Basic namespace selector with custom values' + ); + + $this->assertEquals( + '<label for=namespace>Select a namespace:</label> ' . + '<select id=namespace name=namespace>' . "\n" . + '<option value=0>(Main)</option>' . "\n" . + '<option value=1>Talk</option>' . "\n" . + '<option value=2>User</option>' . "\n" . + '<option value=3>User talk</option>' . "\n" . + '<option value=4>MyWiki</option>' . "\n" . + '<option value=5>MyWiki Talk</option>' . "\n" . + '<option value=6>File</option>' . "\n" . + '<option value=7>File talk</option>' . "\n" . + '<option value=8>MediaWiki</option>' . "\n" . + '<option value=9>MediaWiki talk</option>' . "\n" . + '<option value=10>Template</option>' . "\n" . + '<option value=11>Template talk</option>' . "\n" . + '<option value=14>Category</option>' . "\n" . + '<option value=15>Category talk</option>' . "\n" . + '<option value=100>Custom</option>' . "\n" . + '<option value=101>Custom talk</option>' . "\n" . + '</select>', + Html::namespaceSelector( + array( 'label' => 'Select a namespace:' ) + ), + 'Basic namespace selector with a custom label but no id attribtue for the <select>' + ); + } + + public function testCanFilterOutNamespaces() { + $this->assertEquals( + '<select id=namespace name=namespace>' . "\n" . + '<option value=2>User</option>' . "\n" . + '<option value=4>MyWiki</option>' . "\n" . + '<option value=5>MyWiki Talk</option>' . "\n" . + '<option value=6>File</option>' . "\n" . + '<option value=7>File talk</option>' . "\n" . + '<option value=8>MediaWiki</option>' . "\n" . + '<option value=9>MediaWiki talk</option>' . "\n" . + '<option value=10>Template</option>' . "\n" . + '<option value=11>Template talk</option>' . "\n" . + '<option value=14>Category</option>' . "\n" . + '<option value=15>Category talk</option>' . "\n" . + '</select>', + Html::namespaceSelector( + array( 'exclude' => array( 0, 1, 3, 100, 101 ) ) + ), + 'Namespace selector namespace filtering.' + ); + } + + public function testCanDisableANamespaces() { + $this->assertEquals( + '<select id=namespace name=namespace>' . "\n" . + '<option disabled value=0>(Main)</option>' . "\n" . + '<option disabled value=1>Talk</option>' . "\n" . + '<option disabled value=2>User</option>' . "\n" . + '<option disabled value=3>User talk</option>' . "\n" . + '<option disabled value=4>MyWiki</option>' . "\n" . + '<option value=5>MyWiki Talk</option>' . "\n" . + '<option value=6>File</option>' . "\n" . + '<option value=7>File talk</option>' . "\n" . + '<option value=8>MediaWiki</option>' . "\n" . + '<option value=9>MediaWiki talk</option>' . "\n" . + '<option value=10>Template</option>' . "\n" . + '<option value=11>Template talk</option>' . "\n" . + '<option value=14>Category</option>' . "\n" . + '<option value=15>Category talk</option>' . "\n" . + '<option value=100>Custom</option>' . "\n" . + '<option value=101>Custom talk</option>' . "\n" . + '</select>', + Html::namespaceSelector( array( + 'disable' => array( 0, 1, 2, 3, 4 ) + ) ), + 'Namespace selector namespace disabling' + ); + } + + /** + * @dataProvider provideHtml5InputTypes + * @covers Html::element + */ + public function testHtmlElementAcceptsNewHtml5TypesInHtml5Mode( $HTML5InputType ) { + $this->assertEquals( + '<input type=' . $HTML5InputType . '>', + Html::element( 'input', array( 'type' => $HTML5InputType ) ), + 'In HTML5, HTML::element() should accept type="' . $HTML5InputType . '"' + ); + } + + /** + * List of input element types values introduced by HTML5 + * Full list at http://www.w3.org/TR/html-markup/input.html + */ + public static function provideHtml5InputTypes() { + $types = array( + 'datetime', + 'datetime-local', + 'date', + 'month', + 'time', + 'week', + 'number', + 'range', + 'email', + 'url', + 'search', + 'tel', + 'color', + ); + $cases = array(); + foreach ( $types as $type ) { + $cases[] = array( $type ); + } + + return $cases; + } + + /** + * Test out Html::element drops or enforces default value + * @covers Html::dropDefaults + * @dataProvider provideElementsWithAttributesHavingDefaultValues + */ + public function testDropDefaults( $expected, $element, $attribs, $message = '' ) { + $this->assertEquals( $expected, Html::element( $element, $attribs ), $message ); + } + + public static function provideElementsWithAttributesHavingDefaultValues() { + # Use cases in a concise format: + # <expected>, <element name>, <array of attributes> [, <message>] + # Will be mapped to Html::element() + $cases = array(); + + ### Generic cases, match $attribDefault static array + $cases[] = array( '<area>', + 'area', array( 'shape' => 'rect' ) + ); + + $cases[] = array( '<button type=submit></button>', + 'button', array( 'formaction' => 'GET' ) + ); + $cases[] = array( '<button type=submit></button>', + 'button', array( 'formenctype' => 'application/x-www-form-urlencoded' ) + ); + + $cases[] = array( '<canvas></canvas>', + 'canvas', array( 'height' => '150' ) + ); + $cases[] = array( '<canvas></canvas>', + 'canvas', array( 'width' => '300' ) + ); + # Also check with numeric values + $cases[] = array( '<canvas></canvas>', + 'canvas', array( 'height' => 150 ) + ); + $cases[] = array( '<canvas></canvas>', + 'canvas', array( 'width' => 300 ) + ); + + $cases[] = array( '<command>', + 'command', array( 'type' => 'command' ) + ); + + $cases[] = array( '<form></form>', + 'form', array( 'action' => 'GET' ) + ); + $cases[] = array( '<form></form>', + 'form', array( 'autocomplete' => 'on' ) + ); + $cases[] = array( '<form></form>', + 'form', array( 'enctype' => 'application/x-www-form-urlencoded' ) + ); + + $cases[] = array( '<input>', + 'input', array( 'formaction' => 'GET' ) + ); + $cases[] = array( '<input>', + 'input', array( 'type' => 'text' ) + ); + + $cases[] = array( '<keygen>', + 'keygen', array( 'keytype' => 'rsa' ) + ); + + $cases[] = array( '<link>', + 'link', array( 'media' => 'all' ) + ); + + $cases[] = array( '<menu></menu>', + 'menu', array( 'type' => 'list' ) + ); + + $cases[] = array( '<script></script>', + 'script', array( 'type' => 'text/javascript' ) + ); + + $cases[] = array( '<style></style>', + 'style', array( 'media' => 'all' ) + ); + $cases[] = array( '<style></style>', + 'style', array( 'type' => 'text/css' ) + ); + + $cases[] = array( '<textarea></textarea>', + 'textarea', array( 'wrap' => 'soft' ) + ); + + ### SPECIFIC CASES + + # <link type="text/css"> + $cases[] = array( '<link>', + 'link', array( 'type' => 'text/css' ) + ); + + # <input> specific handling + $cases[] = array( '<input type=checkbox>', + 'input', array( 'type' => 'checkbox', 'value' => 'on' ), + 'Default value "on" is stripped of checkboxes', + ); + $cases[] = array( '<input type=radio>', + 'input', array( 'type' => 'radio', 'value' => 'on' ), + 'Default value "on" is stripped of radio buttons', + ); + $cases[] = array( '<input type=submit value=Submit>', + 'input', array( 'type' => 'submit', 'value' => 'Submit' ), + 'Default value "Submit" is kept on submit buttons (for possible l10n issues)', + ); + $cases[] = array( '<input type=color>', + 'input', array( 'type' => 'color', 'value' => '' ), + ); + $cases[] = array( '<input type=range>', + 'input', array( 'type' => 'range', 'value' => '' ), + ); + + # <button> specific handling + # see remarks on http://msdn.microsoft.com/en-us/library/ie/ms535211%28v=vs.85%29.aspx + $cases[] = array( '<button type=submit></button>', + 'button', array( 'type' => 'submit' ), + 'According to standard the default type is "submit". ' + . 'Depending on compatibility mode IE might use "button", instead.', + ); + + # <select> specifc handling + $cases[] = array( '<select multiple></select>', + 'select', array( 'size' => '4', 'multiple' => true ), + ); + # .. with numeric value + $cases[] = array( '<select multiple></select>', + 'select', array( 'size' => 4, 'multiple' => true ), + ); + $cases[] = array( '<select></select>', + 'select', array( 'size' => '1', 'multiple' => false ), + ); + # .. with numeric value + $cases[] = array( '<select></select>', + 'select', array( 'size' => 1, 'multiple' => false ), + ); + + # Passing an array as value + $cases[] = array( '<a class="css-class-one css-class-two"></a>', + 'a', array( 'class' => array( 'css-class-one', 'css-class-two' ) ), + "dropDefaults accepts values given as an array" + ); + + # FIXME: doDropDefault should remove defaults given in an array + # Expected should be '<a></a>' + $cases[] = array( '<a class=""></a>', + 'a', array( 'class' => array( '', '' ) ), + "dropDefaults accepts values given as an array" + ); + + # Craft the Html elements + $ret = array(); + foreach ( $cases as $case ) { + $ret[] = array( + $case[0], + $case[1], $case[2], + isset( $case[3] ) ? $case[3] : '' + ); + } + + return $ret; + } + + /** + * @covers Html::expandAttributes + */ + public function testFormValidationBlacklist() { + $this->assertEmpty( + Html::expandAttributes( array( + 'min' => 1, + 'max' => 100, + 'pattern' => 'abc', + 'required' => true, + 'step' => 2 + ) ), + 'Blacklist form validation attributes.' + ); + $this->assertEquals( + ' step=any', + Html::expandAttributes( + array( + 'min' => 1, + 'max' => 100, + 'pattern' => 'abc', + 'required' => true, + 'step' => 'any' + ), + 'Allow special case "step=any".' + ) + ); + } + + public function testWrapperInput() { + $this->assertEquals( + '<input type=radio value=testval name=testname>', + Html::input( 'testname', 'testval', 'radio' ), + 'Input wrapper with type and value.' + ); + $this->assertEquals( + '<input name=testname class=mw-ui-input>', + Html::input( 'testname' ), + 'Input wrapper with all default values.' + ); + } + + public function testWrapperCheck() { + $this->assertEquals( + '<input type=checkbox value=1 name=testname>', + Html::check( 'testname' ), + 'Checkbox wrapper unchecked.' + ); + $this->assertEquals( + '<input checked type=checkbox value=1 name=testname>', + Html::check( 'testname', true ), + 'Checkbox wrapper checked.' + ); + $this->assertEquals( + '<input type=checkbox value=testval name=testname>', + Html::check( 'testname', false, array( 'value' => 'testval' ) ), + 'Checkbox wrapper with a value override.' + ); + } + + public function testWrapperRadio() { + $this->assertEquals( + '<input type=radio value=1 name=testname>', + Html::radio( 'testname' ), + 'Radio wrapper unchecked.' + ); + $this->assertEquals( + '<input checked type=radio value=1 name=testname>', + Html::radio( 'testname', true ), + 'Radio wrapper checked.' + ); + $this->assertEquals( + '<input type=radio value=testval name=testname>', + Html::radio( 'testname', false, array( 'value' => 'testval' ) ), + 'Radio wrapper with a value override.' + ); + } + + public function testWrapperLabel() { + $this->assertEquals( + '<label for=testid>testlabel</label>', + Html::label( 'testlabel', 'testid' ), + 'Label wrapper' + ); + } +} + +class HtmlTestValue { + function __toString() { + return 'stringValue'; + } +} diff --git a/tests/phpunit/includes/HttpTest.php b/tests/phpunit/includes/HttpTest.php new file mode 100644 index 00000000..9b53381e --- /dev/null +++ b/tests/phpunit/includes/HttpTest.php @@ -0,0 +1,216 @@ +<?php +/** + * @group Broken + */ +class HttpTest extends MediaWikiTestCase { + /** + * @dataProvider cookieDomains + * @covers Cookie::validateCookieDomain + */ + public function testValidateCookieDomain( $expected, $domain, $origin = null ) { + if ( $origin ) { + $ok = Cookie::validateCookieDomain( $domain, $origin ); + $msg = "$domain against origin $origin"; + } else { + $ok = Cookie::validateCookieDomain( $domain ); + $msg = "$domain"; + } + $this->assertEquals( $expected, $ok, $msg ); + } + + public static function cookieDomains() { + return array( + array( false, "org" ), + array( false, ".org" ), + array( true, "wikipedia.org" ), + array( true, ".wikipedia.org" ), + array( false, "co.uk" ), + array( false, ".co.uk" ), + array( false, "gov.uk" ), + array( false, ".gov.uk" ), + array( true, "supermarket.uk" ), + array( false, "uk" ), + array( false, ".uk" ), + array( false, "127.0.0." ), + array( false, "127." ), + array( false, "127.0.0.1." ), + array( true, "127.0.0.1" ), + array( false, "333.0.0.1" ), + array( true, "example.com" ), + array( false, "example.com." ), + array( true, ".example.com" ), + + array( true, ".example.com", "www.example.com" ), + array( false, "example.com", "www.example.com" ), + array( true, "127.0.0.1", "127.0.0.1" ), + array( false, "127.0.0.1", "localhost" ), + ); + } + + /** + * Test Http::isValidURI() + * @bug 27854 : Http::isValidURI is too lax + * @dataProvider provideURI + * @covers Http::isValidURI + */ + public function testIsValidUri( $expect, $URI, $message = '' ) { + $this->assertEquals( + $expect, + (bool)Http::isValidURI( $URI ), + $message + ); + } + + /** + * Feeds URI to test a long regular expression in Http::isValidURI + */ + public static function provideURI() { + /** Format: 'boolean expectation', 'URI to test', 'Optional message' */ + return array( + array( false, '¿non sens before!! http://a', 'Allow anything before URI' ), + + # (http|https) - only two schemes allowed + array( true, 'http://www.example.org/' ), + array( true, 'https://www.example.org/' ), + array( true, 'http://www.example.org', 'URI without directory' ), + array( true, 'http://a', 'Short name' ), + array( true, 'http://étoile', 'Allow UTF-8 in hostname' ), # 'étoile' is french for 'star' + array( false, '\\host\directory', 'CIFS share' ), + array( false, 'gopher://host/dir', 'Reject gopher scheme' ), + array( false, 'telnet://host', 'Reject telnet scheme' ), + + # :\/\/ - double slashes + array( false, 'http//example.org', 'Reject missing colon in protocol' ), + array( false, 'http:/example.org', 'Reject missing slash in protocol' ), + array( false, 'http:example.org', 'Must have two slashes' ), + # Following fail since hostname can be made of anything + array( false, 'http:///example.org', 'Must have exactly two slashes, not three' ), + + # (\w+:{0,1}\w*@)? - optional user:pass + array( true, 'http://user@host', 'Username provided' ), + array( true, 'http://user:@host', 'Username provided, no password' ), + array( true, 'http://user:pass@host', 'Username and password provided' ), + + # (\S+) - host part is made of anything not whitespaces + array( false, 'http://!"èèè¿¿¿~~\'', 'hostname is made of any non whitespace' ), + array( false, 'http://exam:ple.org/', 'hostname can not use colons!' ), + + # (:[0-9]+)? - port number + array( true, 'http://example.org:80/' ), + array( true, 'https://example.org:80/' ), + array( true, 'http://example.org:443/' ), + array( true, 'https://example.org:443/' ), + + # Part after the hostname is / or / with something else + array( true, 'http://example/#' ), + array( true, 'http://example/!' ), + array( true, 'http://example/:' ), + array( true, 'http://example/.' ), + array( true, 'http://example/?' ), + array( true, 'http://example/+' ), + array( true, 'http://example/=' ), + array( true, 'http://example/&' ), + array( true, 'http://example/%' ), + array( true, 'http://example/@' ), + array( true, 'http://example/-' ), + array( true, 'http://example//' ), + array( true, 'http://example/&' ), + + # Fragment + array( true, 'http://exam#ple.org', ), # This one is valid, really! + array( true, 'http://example.org:80#anchor' ), + array( true, 'http://example.org/?id#anchor' ), + array( true, 'http://example.org/?#anchor' ), + + array( false, 'http://a ¿non !!sens after', 'Allow anything after URI' ), + ); + } + + /** + * Warning: + * + * These tests are for code that makes use of an artifact of how CURL + * handles header reporting on redirect pages, and will need to be + * rewritten when bug 29232 is taken care of (high-level handling of + * HTTP redirects). + */ + public function testRelativeRedirections() { + $h = MWHttpRequestTester::factory( 'http://oldsite/file.ext' ); + + # Forge a Location header + $h->setRespHeaders( 'location', array( + 'http://newsite/file.ext', + '/newfile.ext', + ) + ); + # Verify we correctly fix the Location + $this->assertEquals( + 'http://newsite/newfile.ext', + $h->getFinalUrl(), + "Relative file path Location: interpreted as full URL" + ); + + $h->setRespHeaders( 'location', array( + 'https://oldsite/file.ext' + ) + ); + $this->assertEquals( + 'https://oldsite/file.ext', + $h->getFinalUrl(), + "Location to the HTTPS version of the site" + ); + + $h->setRespHeaders( 'location', array( + '/anotherfile.ext', + 'http://anotherfile/hoster.ext', + 'https://anotherfile/hoster.ext' + ) + ); + $this->assertEquals( + 'https://anotherfile/hoster.ext', + $h->getFinalUrl( "Relative file path Location: should keep the latest host and scheme!" ) + ); + } +} + +/** + * Class to let us overwrite MWHttpRequest respHeaders variable + */ +class MWHttpRequestTester extends MWHttpRequest { + // function derived from the MWHttpRequest factory function but + // returns appropriate tester class here + public static function factory( $url, $options = null ) { + if ( !Http::$httpEngine ) { + Http::$httpEngine = function_exists( 'curl_init' ) ? 'curl' : 'php'; + } elseif ( Http::$httpEngine == 'curl' && !function_exists( 'curl_init' ) ) { + throw new MWException( __METHOD__ . ': curl (http://php.net/curl) is not installed, but' . + 'Http::$httpEngine is set to "curl"' ); + } + + switch ( Http::$httpEngine ) { + case 'curl': + return new CurlHttpRequestTester( $url, $options ); + case 'php': + if ( !wfIniGetBool( 'allow_url_fopen' ) ) { + throw new MWException( __METHOD__ . + ': allow_url_fopen needs to be enabled for pure PHP HTTP requests to work. ' + . 'If possible, curl should be used instead. See http://php.net/curl.' ); + } + + return new PhpHttpRequestTester( $url, $options ); + default: + } + } +} + +class CurlHttpRequestTester extends CurlHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} + +class PhpHttpRequestTester extends PhpHttpRequest { + function setRespHeaders( $name, $value ) { + $this->respHeaders[$name] = $value; + } +} diff --git a/tests/phpunit/includes/ImagePage404Test.php b/tests/phpunit/includes/ImagePage404Test.php new file mode 100644 index 00000000..197a2b32 --- /dev/null +++ b/tests/phpunit/includes/ImagePage404Test.php @@ -0,0 +1,53 @@ +<?php +/** + * For doing Image Page tests that rely on 404 thumb handling + */ +class ImagePage404Test extends MediaWikiMediaTestCase { + + protected function getRepoOptions() { + return parent::getRepoOptions() + array( 'transformVia404' => true ); + } + + function setUp() { + $this->setMwGlobals( 'wgImageLimits', array( + array( 320, 240 ), + array( 640, 480 ), + array( 800, 600 ), + array( 1024, 768 ), + array( 1280, 1024 ) + ) ); + parent::setUp(); + } + + function getImagePage( $filename ) { + $title = Title::makeTitleSafe( NS_FILE, $filename ); + $file = $this->dataFile( $filename ); + $iPage = new ImagePage( $title ); + $iPage->setFile( $file ); + return $iPage; + } + + /** + * @dataProvider providerGetThumbSizes + * @param string $filename + * @param int $expectedNumberThumbs How many thumbnails to show + */ + function testGetThumbSizes( $filename, $expectedNumberThumbs ) { + $iPage = $this->getImagePage( $filename ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getThumbSizes' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, 545, 700 ); + $this->assertEquals( count( $actual ), $expectedNumberThumbs ); + } + + function providerGetThumbSizes() { + return array( + array( 'animated.gif', 6 ), + array( 'Toll_Texas_1.svg', 6 ), + array( '80x60-Greyscale.xcf', 6 ), + array( 'jpeg-comment-binary.jpg', 6 ), + ); + } +} diff --git a/tests/phpunit/includes/ImagePageTest.php b/tests/phpunit/includes/ImagePageTest.php new file mode 100644 index 00000000..3c255b5f --- /dev/null +++ b/tests/phpunit/includes/ImagePageTest.php @@ -0,0 +1,90 @@ +<?php +class ImagePageTest extends MediaWikiMediaTestCase { + + function setUp() { + $this->setMwGlobals( 'wgImageLimits', array( + array( 320, 240 ), + array( 640, 480 ), + array( 800, 600 ), + array( 1024, 768 ), + array( 1280, 1024 ) + ) ); + parent::setUp(); + } + + function getImagePage( $filename ) { + $title = Title::makeTitleSafe( NS_FILE, $filename ); + $file = $this->dataFile( $filename ); + $iPage = new ImagePage( $title ); + $iPage->setFile( $file ); + return $iPage; + } + + /** + * @dataProvider providerGetDisplayWidthHeight + * @param array $dim Array [maxWidth, maxHeight, width, height] + * @param array $expected Array [width, height] The width and height we expect to display at + */ + function testGetDisplayWidthHeight( $dim, $expected ) { + $iPage = $this->getImagePage( 'animated.gif' ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getDisplayWidthHeight' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, $dim[0], $dim[1], $dim[2], $dim[3] ); + $this->assertEquals( $actual, $expected ); + } + + function providerGetDisplayWidthHeight() { + return array( + array( + array( 1024.0, 768.0, 600.0, 600.0 ), + array( 600.0, 600.0 ) + ), + array( + array( 1024.0, 768.0, 1600.0, 600.0 ), + array( 1024.0, 384.0 ) + ), + array( + array( 1024.0, 768.0, 1024.0, 768.0 ), + array( 1024.0, 768.0 ) + ), + array( + array( 1024.0, 768.0, 800.0, 1000.0 ), + array( 614.0, 768.0 ) + ), + array( + array( 1024.0, 768.0, 0, 1000 ), + array( 0, 0 ) + ), + array( + array( 1024.0, 768.0, 2000, 0 ), + array( 0, 0 ) + ), + ); + } + + /** + * @dataProvider providerGetThumbSizes + * @param string $filename + * @param int $expectedNumberThumbs How many thumbnails to show + */ + function testGetThumbSizes( $filename, $expectedNumberThumbs ) { + $iPage = $this->getImagePage( $filename ); + $reflection = new ReflectionClass( $iPage ); + $reflMethod = $reflection->getMethod( 'getThumbSizes' ); + $reflMethod->setAccessible( true ); + + $actual = $reflMethod->invoke( $iPage, 545, 700 ); + $this->assertEquals( count( $actual ), $expectedNumberThumbs ); + } + + function providerGetThumbSizes() { + return array( + array( 'animated.gif', 2 ), + array( 'Toll_Texas_1.svg', 1 ), + array( '80x60-Greyscale.xcf', 1 ), + array( 'jpeg-comment-binary.jpg', 2 ), + ); + } +} diff --git a/tests/phpunit/includes/ImportTest.php b/tests/phpunit/includes/ImportTest.php new file mode 100644 index 00000000..2fce6bfb --- /dev/null +++ b/tests/phpunit/includes/ImportTest.php @@ -0,0 +1,101 @@ +<?php + +/** + * Test class for Import methods. + * + * @group Database + * + * @author Sebastian Brückner < sebastian.brueckner@student.hpi.uni-potsdam.de > + */ +class ImportTest extends MediaWikiLangTestCase { + + private function getInputStreamSource( $xml ) { + $file = 'data:application/xml,' . $xml; + $status = ImportStreamSource::newFromFile( $file ); + if ( !$status->isGood() ) { + throw new MWException( "Cannot create InputStreamSource." ); + } + return $status->value; + } + + /** + * @covers WikiImporter::handlePage + * @dataProvider getRedirectXML + * @param string $xml + * @param string|null $redirectTitle + */ + public function testHandlePageContainsRedirect( $xml, $redirectTitle ) { + $source = $this->getInputStreamSource( $xml ); + + $redirect = null; + $callback = function ( $title, $origTitle, $revCount, $sRevCount, $pageInfo ) use ( &$redirect ) { + if ( array_key_exists( 'redirect', $pageInfo ) ) { + $redirect = $pageInfo['redirect']; + } + }; + + $importer = new WikiImporter( $source ); + $importer->setPageOutCallback( $callback ); + $importer->doImport(); + + $this->assertEquals( $redirectTitle, $redirect ); + } + + public function getRedirectXML() { + return array( + array( + <<< EOF +<mediawiki> + <page> + <title>Test</title> + <ns>0</ns> + <id>21</id> + <redirect title="Test22"/> + <revision> + <id>20</id> + <timestamp>2014-05-27T10:00:00Z</timestamp> + <contributor> + <username>Admin</username> + <id>10</id> + </contributor> + <comment>Admin moved page [[Test]] to [[Test22]]</comment> + <text xml:space="preserve" bytes="20">#REDIRECT [[Test22]]</text> + <sha1>tq456o9x3abm7r9ozi6km8yrbbc56o6</sha1> + <model>wikitext</model> + <format>text/x-wiki</format> + </revision> + </page> +</mediawiki> +EOF + , + 'Test22' + ), + array( + <<< EOF +<mediawiki> + <page> + <title>Test</title> + <ns>0</ns> + <id>42</id> + <revision> + <id>421</id> + <timestamp>2014-05-27T11:00:00Z</timestamp> + <contributor> + <username>Admin</username> + <id>10</id> + </contributor> + <text xml:space="preserve" bytes="4">Abcd</text> + <sha1>n7uomjq96szt60fy5w3x7ahf7q8m8rh</sha1> + <model>wikitext</model> + <format>text/x-wiki</format> + </revision> + </page> +</mediawiki> +EOF + , + null + ), + ); + } + +} diff --git a/tests/phpunit/includes/LanguageConverterTest.php b/tests/phpunit/includes/LanguageConverterTest.php new file mode 100644 index 00000000..d4ccca99 --- /dev/null +++ b/tests/phpunit/includes/LanguageConverterTest.php @@ -0,0 +1,187 @@ +<?php + +class LanguageConverterTest extends MediaWikiLangTestCase { + /** @var LanguageToTest */ + protected $lang = null; + /** @var TestConverter */ + protected $lc = null; + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'tg' ), + 'wgLanguageCode' => 'tg', + 'wgDefaultLanguageVariant' => false, + 'wgMemc' => new EmptyBagOStuff, + 'wgRequest' => new FauxRequest( array() ), + 'wgUser' => new User, + ) ); + + $this->lang = new LanguageToTest(); + $this->lc = new TestConverter( + $this->lang, 'tg', + array( 'tg', 'tg-latn' ) + ); + } + + protected function tearDown() { + unset( $this->lc ); + unset( $this->lang ); + + parent::tearDown(); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaults() { + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaders() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderWeight() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg;q=1' ); + + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderWeight2() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'tg-latn;q=1' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getHeaderVariant + */ + public function testGetPreferredVariantHeaderMulti() { + global $wgRequest; + $wgRequest->setHeader( 'Accept-Language', 'en, tg-latn;q=1' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantUserOption() { + global $wgUser; + + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + */ + public function testGetPreferredVariantUserOptionForForeignLanguage() { + global $wgContLang, $wgUser; + + $wgContLang = Language::factory( 'en' ); + $wgUser = new User; + $wgUser->load(); // from 'defaults' + $wgUser->mId = 1; + $wgUser->mDataLoaded = true; + $wgUser->mOptionsLoaded = true; + $wgUser->setOption( 'variant-tg', 'tg-latn' ); + + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getUserVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantHeaderUserVsUrl() { + global $wgContLang, $wgRequest, $wgUser; + + $wgContLang = Language::factory( 'tg-latn' ); + $wgRequest->setVal( 'variant', 'tg' ); + $wgUser = User::newFromId( "admin" ); + $wgUser->setId( 1 ); + $wgUser->mFrom = 'defaults'; + $wgUser->mOptionsLoaded = true; + // The user's data is ignored because the variant is set in the URL. + $wgUser->setOption( 'variant', 'tg-latn' ); + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + */ + public function testGetPreferredVariantDefaultLanguageVariant() { + global $wgDefaultLanguageVariant; + + $wgDefaultLanguageVariant = 'tg-latn'; + $this->assertEquals( 'tg-latn', $this->lc->getPreferredVariant() ); + } + + /** + * @covers LanguageConverter::getPreferredVariant + * @covers LanguageConverter::getURLVariant + */ + public function testGetPreferredVariantDefaultLanguageVsUrlVariant() { + global $wgDefaultLanguageVariant, $wgRequest, $wgContLang; + + $wgContLang = Language::factory( 'tg-latn' ); + $wgDefaultLanguageVariant = 'tg'; + $wgRequest->setVal( 'variant', null ); + $this->assertEquals( 'tg', $this->lc->getPreferredVariant() ); + } +} + +/** + * Test converter (from Tajiki to latin orthography) + */ +class TestConverter extends LanguageConverter { + private $table = array( + 'б' => 'b', + 'в' => 'v', + 'г' => 'g', + ); + + function loadDefaultTables() { + $this->mTables = array( + 'tg-latn' => new ReplacementArray( $this->table ), + 'tg' => new ReplacementArray() + ); + } +} + +class LanguageToTest extends Language { + function __construct() { + parent::__construct(); + $variants = array( 'tg', 'tg-latn' ); + $this->mConverter = new TestConverter( $this, 'tg', $variants ); + } +} diff --git a/tests/phpunit/includes/LicensesTest.php b/tests/phpunit/includes/LicensesTest.php new file mode 100644 index 00000000..63b2c395 --- /dev/null +++ b/tests/phpunit/includes/LicensesTest.php @@ -0,0 +1,25 @@ +<?php + +/** + * @covers Licenses + */ +class LicensesTest extends MediaWikiTestCase { + + public function testLicenses() { + $str = " +* Free licenses: +** GFDL|Debian disagrees +"; + + $lc = new Licenses( array( + 'fieldname' => 'FooField', + 'type' => 'select', + 'section' => 'description', + 'id' => 'wpLicense', + 'label' => 'A label text', # Note can't test label-message because $wgOut is not defined + 'name' => 'AnotherName', + 'licenses' => $str, + ) ); + $this->assertThat( $lc, $this->isInstanceOf( 'Licenses' ) ); + } +} diff --git a/tests/phpunit/includes/LinkFilterTest.php b/tests/phpunit/includes/LinkFilterTest.php new file mode 100644 index 00000000..f2c9cb43 --- /dev/null +++ b/tests/phpunit/includes/LinkFilterTest.php @@ -0,0 +1,274 @@ +<?php + +/** + * @group Database + */ +class LinkFilterTest extends MediaWikiLangTestCase { + + protected function setUp() { + + parent::setUp(); + + $this->setMwGlobals( 'wgUrlProtocols', array( + 'http://', + 'https://', + 'ftp://', + 'irc://', + 'ircs://', + 'gopher://', + 'telnet://', + 'nntp://', + 'worldwind://', + 'mailto:', + 'news:', + 'svn://', + 'git://', + 'mms://', + '//', + ) ); + + } + + /** + * createRegexFromLike($like) + * + * Takes an array as created by LinkFilter::makeLikeArray() and creates a regex from it + * + * @param array $like Array as created by LinkFilter::makeLikeArray() + * @return string Regex + */ + function createRegexFromLIKE( $like ) { + + $regex = '!^'; + + foreach ( $like as $item ) { + + if ( $item instanceof LikeMatch ) { + if ( $item->toString() == '%' ) { + $regex .= '.*'; + } elseif ( $item->toString() == '_' ) { + $regex .= '.'; + } + } else { + $regex .= preg_quote( $item, '!' ); + } + + } + + $regex .= '$!'; + + return $regex; + + } + + /** + * provideValidPatterns() + * + * @return array + */ + public static function provideValidPatterns() { + + return array( + // Protocol, Search pattern, URL which matches the pattern + array( 'http://', '*.test.com', 'http://www.test.com' ), + array( 'http://', 'test.com:8080/dir/file', 'http://name:pass@test.com:8080/dir/file' ), + array( 'https://', '*.com', 'https://s.s.test..com:88/dir/file?a=1&b=2' ), + array( 'https://', '*.com', 'https://name:pass@secure.com/index.html' ), + array( 'http://', 'name:pass@test.com', 'http://test.com' ), + array( 'http://', 'test.com', 'http://name:pass@test.com' ), + array( 'http://', '*.test.com', 'http://a.b.c.test.com/dir/dir/file?a=6'), + array( null, 'http://*.test.com', 'http://www.test.com' ), + array( 'mailto:', 'name@mail.test123.com', 'mailto:name@mail.test123.com' ), + array( '', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' + ), + array( '', 'http://name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( '', 'http://name:wrongpass@*.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( 'http://', 'name:pass@*.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( '', 'http://name:pass@www.test.com:12345', + 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' ), + array( 'ftp://', 'user:pass@ftp.test.com:1233/home/user/file;type=efw', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://otheruser:otherpass@ftp.test.com:1233/home/user/file;type=', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://@ftp.test.com:1233/home/user/file;type=', + 'ftp://user:pass@ftp.test.com:1233/home/user/file;type=efw' ), + array( null, 'ftp://ftp.test.com/', + 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ), + array( null, 'ftp://ftp.test.com/', + 'ftp://user:pass@ftp.test.com/home/user/file;type=efw' ), + array( null, 'ftp://*.test.com:222/', + 'ftp://user:pass@ftp.test.com:222/home' ), + array( 'irc://', '*.myserver:6667/', 'irc://test.myserver:6667/' ), + array( 'irc://', 'name:pass@*.myserver/', 'irc://test.myserver:6667/' ), + array( 'irc://', 'name:pass@*.myserver/', 'irc://other:@test.myserver:6667/' ), + array( '', 'irc://test/name,string,abc?msg=t', 'irc://test/name,string,abc?msg=test' ), + array( '', 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ), + array( '', 'https://gerrit.wikimedia.org', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z' ), + array( 'mailto:', '*.test.com', 'mailto:name@pop3.test.com' ), + array( 'mailto:', 'test.com', 'mailto:name@test.com' ), + array( 'news:', 'test.1234afc@news.test.com', 'news:test.1234afc@news.test.com' ), + array( 'news:', '*.test.com', 'news:test.1234afc@news.test.com' ), + array( '', 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com', + 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ), + array( '', 'news:*.aol.com', + 'news:4df8kh$iagfewewf(at)newsbf02aaa.news.aol.com' ), + array( '', 'git://github.com/prwef/abc-def.git', 'git://github.com/prwef/abc-def.git' ), + array( 'git://', 'github.com/', 'git://github.com/prwef/abc-def.git' ), + array( 'git://', '*.github.com/', 'git://a.b.c.d.e.f.github.com/prwef/abc-def.git' ), + array( '', 'gopher://*.test.com/', 'gopher://gopher.test.com/0/v2/vstat'), + array( 'telnet://', '*.test.com', 'telnet://shell.test.com/~home/'), + + // + // The following only work in PHP >= 5.3.7, due to a bug in parse_url which eats + // the path from the url (https://bugs.php.net/bug.php?id=54180) + // + // array( '', 'http://test.com', 'http://test.com/index?arg=1' ), + // array( 'http://', '*.test.com', 'http://www.test.com/index?arg=1' ), + // array( '' , + // 'http://xx23124:__ffdfdef__@www.test.com:12345/dir' , + // 'http://name:pass@www.test.com:12345/dir/dir/file.xyz.php#__se__?arg1=_&arg2[]=4rtg' + // ), + // + + // + // Tests for false positives + // + array( 'http://', 'test.com', 'http://www.test.com', false ), + array( 'http://', 'www1.test.com', 'http://www.test.com', false ), + array( 'http://', '*.test.com', 'http://www.test.t.com', false ), + array( '', 'http://test.com:8080', 'http://www.test.com:8080', false ), + array( '', 'https://test.com', 'http://test.com', false ), + array( '', 'http://test.com', 'https://test.com', false ), + array( 'http://', 'http://test.com', 'http://test.com', false ), + array( null, 'http://www.test.com', 'http://www.test.com:80', false ), + array( null, 'http://www.test.com:80', 'http://www.test.com', false ), + array( null, 'http://*.test.com:80', 'http://www.test.com', false ), + array( '', 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/q/status:open,n,z', false ), + array( '', 'https://*.wikimedia.org/r/#/q/status:open,n,z', + 'https://gerrit.wikimedia.org/r/#/XXX/status:open,n,z', false ), + array( 'mailto:', '@test.com', '@abc.test.com', false ), + array( 'mailto:', 'mail@test.com', 'mail2@test.com', false ), + array( '', 'mailto:mail@test.com', 'mail2@test.com', false ), + array( '', 'mailto:@test.com', '@abc.test.com', false ), + array( 'ftp://', '*.co', 'ftp://www.co.uk', false ), + array( 'ftp://', '*.co', 'ftp://www.co.m', false ), + array( 'ftp://', '*.co/dir/', 'ftp://www.co/dir2/', false ), + array( 'ftp://', 'www.co/dir/', 'ftp://www.co/dir2/', false ), + array( 'ftp://', 'test.com/dir/', 'ftp://test.com/', false ), + array( '', 'http://test.com:8080/dir/', 'http://test.com:808/dir/', false ), + array( '', 'http://test.com/dir/index.html', 'http://test.com/dir/index.php', false ), + + // + // These are false positives too and ideally shouldn't match, but that + // would require using regexes and RLIKE instead of LIKE + // + // array( null, 'http://*.test.com', 'http://www.test.com:80', false ), + // array( '', 'https://*.wikimedia.org/r/#/q/status:open,n,z', + // 'https://gerrit.wikimedia.org/XXX/r/#/q/status:open,n,z', false ), + ); + + } + + /** + * testMakeLikeArrayWithValidPatterns() + * + * Tests whether the LIKE clause produced by LinkFilter::makeLikeArray($pattern, $protocol) + * will find one of the URL indexes produced by wfMakeUrlIndexes($url) + * + * @dataProvider provideValidPatterns + * + * @param string $protocol Protocol, e.g. 'http://' or 'mailto:' + * @param string $pattern Search pattern to feed to LinkFilter::makeLikeArray + * @param string $url URL to feed to wfMakeUrlIndexes + * @param bool $shouldBeFound Should the URL be found? (defaults true) + */ + function testMakeLikeArrayWithValidPatterns( $protocol, $pattern, $url, $shouldBeFound = true ) { + + $indexes = wfMakeUrlIndexes( $url ); + $likeArray = LinkFilter::makeLikeArray( $pattern, $protocol ); + + $this->assertTrue( $likeArray !== false, + "LinkFilter::makeLikeArray('$pattern', '$protocol') returned false on a valid pattern" + ); + + $regex = $this->createRegexFromLIKE( $likeArray ); + $debugmsg = "Regex: '" . $regex . "'\n"; + $debugmsg .= count( $indexes ) . " index(es) created by wfMakeUrlIndexes():\n"; + + $matches = 0; + + foreach ( $indexes as $index ) { + $matches += preg_match( $regex, $index ); + $debugmsg .= "\t'$index'\n"; + } + + if ( $shouldBeFound ) { + $this->assertTrue( + $matches > 0, + "Search pattern '$protocol$pattern' does not find url '$url' \n$debugmsg" + ); + } else { + $this->assertFalse( + $matches > 0, + "Search pattern '$protocol$pattern' should not find url '$url' \n$debugmsg" + ); + } + + } + + /** + * provideInvalidPatterns() + * + * @return array + */ + public static function provideInvalidPatterns() { + + return array( + array( '' ), + array( '*' ), + array( 'http://*' ), + array( 'http://*/' ), + array( 'http://*/dir/file' ), + array( 'test.*.com' ), + array( 'http://test.*.com' ), + array( 'test.*.com' ), + array( 'http://*.test.*' ), + array( 'http://*test.com' ), + array( 'https://*' ), + array( '*://test.com'), + array( 'mailto:name:pass@t*est.com' ), + array( 'http://*:888/'), + array( '*http://'), + array( 'test.com/*/index' ), + array( 'test.com/dir/index?arg=*' ), + ); + + } + + /** + * testMakeLikeArrayWithInvalidPatterns() + * + * Tests whether LinkFilter::makeLikeArray($pattern) will reject invalid search patterns + * + * @dataProvider provideInvalidPatterns + * + * @param string $pattern Invalid search pattern + */ + function testMakeLikeArrayWithInvalidPatterns( $pattern ) { + + $this->assertFalse( + LinkFilter::makeLikeArray( $pattern ), + "'$pattern' is not a valid pattern and should be rejected" + ); + + } + +} diff --git a/tests/phpunit/includes/LinkerTest.php b/tests/phpunit/includes/LinkerTest.php new file mode 100644 index 00000000..7b84107e --- /dev/null +++ b/tests/phpunit/includes/LinkerTest.php @@ -0,0 +1,192 @@ +<?php + +/** + * @group Database + */ + +class LinkerTest extends MediaWikiLangTestCase { + + /** + * @dataProvider provideCasesForUserLink + * @covers Linker::userLink + */ + public function testUserLink( $expected, $userId, $userName, $altUserName = false, $msg = '' ) { + $this->setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1', + 'wgWellFormedXml' => true, + ) ); + + $this->assertEquals( $expected, + Linker::userLink( $userId, $userName, $altUserName, $msg ) + ); + } + + public static function provideCasesForUserLink() { + # Format: + # - expected + # - userid + # - username + # - optional altUserName + # - optional message + return array( + + ### ANONYMOUS USER ######################################## + array( + '<a href="/wiki/Special:Contributions/JohnDoe" ' + . 'title="Special:Contributions/JohnDoe" ' + . 'class="mw-userlink mw-anonuserlink">JohnDoe</a>', + 0, 'JohnDoe', false, + ), + array( + '<a href="/wiki/Special:Contributions/::1" ' + . 'title="Special:Contributions/::1" ' + . 'class="mw-userlink mw-anonuserlink">::1</a>', + 0, '::1', false, + 'Anonymous with pretty IPv6' + ), + array( + '<a href="/wiki/Special:Contributions/0:0:0:0:0:0:0:1" ' + . 'title="Special:Contributions/0:0:0:0:0:0:0:1" ' + . 'class="mw-userlink mw-anonuserlink">::1</a>', + 0, '0:0:0:0:0:0:0:1', false, + 'Anonymous with almost pretty IPv6' + ), + array( + '<a href="/wiki/Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001" ' + . 'title="Special:Contributions/0000:0000:0000:0000:0000:0000:0000:0001" ' + . 'class="mw-userlink mw-anonuserlink">::1</a>', + 0, '0000:0000:0000:0000:0000:0000:0000:0001', false, + 'Anonymous with full IPv6' + ), + array( + '<a href="/wiki/Special:Contributions/::1" ' + . 'title="Special:Contributions/::1" ' + . 'class="mw-userlink mw-anonuserlink">AlternativeUsername</a>', + 0, '::1', 'AlternativeUsername', + 'Anonymous with pretty IPv6 and an alternative username' + ), + + # IPV4 + array( + '<a href="/wiki/Special:Contributions/127.0.0.1" ' + . 'title="Special:Contributions/127.0.0.1" ' + . 'class="mw-userlink mw-anonuserlink">127.0.0.1</a>', + 0, '127.0.0.1', false, + 'Anonymous with IPv4' + ), + array( + '<a href="/wiki/Special:Contributions/127.0.0.1" ' + . 'title="Special:Contributions/127.0.0.1" ' + . 'class="mw-userlink mw-anonuserlink">AlternativeUsername</a>', + 0, '127.0.0.1', 'AlternativeUsername', + 'Anonymous with IPv4 and an alternative username' + ), + + ### Regular user ########################################## + # TODO! + ); + } + + /** + * @dataProvider provideCasesForFormatComment + * @covers Linker::formatComment + * @covers Linker::formatAutocomments + * @covers Linker::formatLinksInComment + */ + public function testFormatComment( $expected, $comment, $title = false, $local = false ) { + $this->setMwGlobals( array( + 'wgScript' => '/wiki/index.php', + 'wgArticlePath' => '/wiki/$1', + 'wgWellFormedXml' => true, + 'wgCapitalLinks' => true, + ) ); + + if ( $title === false ) { + // We need a page title that exists + $title = Title::newFromText( 'Special:BlankPage' ); + } + + $this->assertEquals( + $expected, + Linker::formatComment( $comment, $title, $local ) + ); + } + + public static function provideCasesForFormatComment() { + return array( + // Linker::formatComment + array( + 'a<script>b', + 'a<script>b', + ), + array( + 'a—b', + 'a—b', + ), + array( + "'''not bolded'''", + "'''not bolded'''", + ), + // Linker::formatAutocomments + array( + '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment">autocomment</span></span>', + "/* autocomment */", + ), + array( + '<a href="/wiki/Special:BlankPage#linkie.3F" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment"><a href="/wiki/index.php?title=Linkie%3F&action=edit&redlink=1" class="new" title="Linkie? (page does not exist)">linkie?</a></span></span>', + "/* [[linkie?]] */", + ), + array( + '<a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment">autocomment: </span> post</span>', + "/* autocomment */ post", + ), + array( + 'pre <a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment">autocomment</span></span>', + "pre /* autocomment */", + ), + array( + 'pre <a href="/wiki/Special:BlankPage#autocomment" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment">autocomment: </span> post</span>', + "pre /* autocomment */ post", + ), + array( + '/* autocomment */ multiple? <a href="/wiki/Special:BlankPage#autocomment2" title="Special:BlankPage">→</a><span dir="auto"><span class="autocomment">autocomment2: </span> </span>', + "/* autocomment */ multiple? /* autocomment2 */ ", + ), + array( + '<a href="#autocomment">→</a><span dir="auto"><span class="autocomment">autocomment</span></span>', + "/* autocomment */", + false, true + ), + array( + '<span dir="auto"><span class="autocomment">autocomment</span></span>', + "/* autocomment */", + null + ), + // Linker::formatLinksInComment + array( + 'abc <a href="/wiki/index.php?title=Link&action=edit&redlink=1" class="new" title="Link (page does not exist)">link</a> def', + "abc [[link]] def", + ), + array( + 'abc <a href="/wiki/index.php?title=Link&action=edit&redlink=1" class="new" title="Link (page does not exist)">text</a> def', + "abc [[link|text]] def", + ), + array( + 'abc <a href="/wiki/Special:BlankPage" title="Special:BlankPage">Special:BlankPage</a> def', + "abc [[Special:BlankPage|]] def", + ), + array( + 'abc <a href="/wiki/index.php?title=%C4%84%C5%9B%C5%BC&action=edit&redlink=1" class="new" title="Ąśż (page does not exist)">ąśż</a> def', + "abc [[%C4%85%C5%9B%C5%BC]] def", + ), + array( + 'abc <a href="/wiki/Special:BlankPage#section" title="Special:BlankPage">#section</a> def', + "abc [[#section]] def", + ), + array( + 'abc <a href="/wiki/index.php?title=/subpage&action=edit&redlink=1" class="new" title="/subpage (page does not exist)">/subpage</a> def', + "abc [[/subpage]] def", + ), + ); + } +} diff --git a/tests/phpunit/includes/LinksUpdateTest.php b/tests/phpunit/includes/LinksUpdateTest.php new file mode 100644 index 00000000..02f6b2ab --- /dev/null +++ b/tests/phpunit/includes/LinksUpdateTest.php @@ -0,0 +1,266 @@ +<?php + +/** + * @group Database + * ^--- make sure temporary tables are used. + */ +class LinksUpdateTest extends MediaWikiTestCase { + + function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed = array_merge( $this->tablesUsed, + array( + 'interwiki', + 'page_props', + 'pagelinks', + 'categorylinks', + 'langlinks', + 'externallinks', + 'imagelinks', + 'templatelinks', + 'iwlinks' + ) + ); + } + + protected function setUp() { + parent::setUp(); + $dbw = wfGetDB( DB_MASTER ); + $dbw->replace( + 'interwiki', + array( 'iw_prefix' ), + array( + 'iw_prefix' => 'linksupdatetest', + 'iw_url' => 'http://testing.com/wiki/$1', + 'iw_api' => 'http://testing.com/w/api.php', + 'iw_local' => 0, + 'iw_trans' => 0, + 'iw_wikiid' => 'linksupdatetest', + ) + ); + } + + protected function makeTitleAndParserOutput( $name, $id ) { + $t = Title::newFromText( $name ); + $t->mArticleID = $id; # XXX: this is fugly + + $po = new ParserOutput(); + $po->setTitleText( $t->getPrefixedText() ); + + return array( $t, $po ); + } + + /** + * @covers ParserOutput::addLink + */ + public function testUpdate_pagelinks() { + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addLink( Title::newFromText( "Foo" ) ); + $po->addLink( Title::newFromText( "Special:Foo" ) ); // special namespace should be ignored + $po->addLink( Title::newFromText( "linksupdatetest:Foo" ) ); // interwiki link should be ignored + $po->addLink( Title::newFromText( "#Foo" ) ); // hash link should be ignored + + $update = $this->assertLinksUpdate( + $t, + $po, + 'pagelinks', + 'pl_namespace, + pl_title', + 'pl_from = 111', + array( array( NS_MAIN, 'Foo' ) ) + ); + $this->assertArrayEquals( array( + Title::makeTitle( NS_MAIN, 'Foo' ), // newFromText doesn't yield the same internal state.... + ), $update->getAddedLinks() ); + + $po = new ParserOutput(); + $po->setTitleText( $t->getPrefixedText() ); + + $po->addLink( Title::newFromText( "Bar" ) ); + $po->addLink( Title::newFromText( "Talk:Bar" ) ); + + $update = $this->assertLinksUpdate( + $t, + $po, + 'pagelinks', + 'pl_namespace, + pl_title', + 'pl_from = 111', + array( + array( NS_MAIN, 'Bar' ), + array( NS_TALK, 'Bar' ), + ) + ); + $this->assertArrayEquals( array( + Title::makeTitle( NS_MAIN, 'Bar' ), + Title::makeTitle( NS_TALK, 'Bar' ), + ), $update->getAddedLinks() ); + $this->assertArrayEquals( array( + Title::makeTitle( NS_MAIN, 'Foo' ), + ), $update->getRemovedLinks() ); + } + + /** + * @covers ParserOutput::addExternalLink + */ + public function testUpdate_externallinks() { + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addExternalLink( "http://testing.com/wiki/Foo" ); + + $this->assertLinksUpdate( $t, $po, 'externallinks', 'el_to, el_index', 'el_from = 111', array( + array( 'http://testing.com/wiki/Foo', 'http://com.testing./wiki/Foo' ), + ) ); + } + + /** + * @covers ParserOutput::addCategory + */ + public function testUpdate_categorylinks() { + /** @var ParserOutput $po */ + $this->setMwGlobals( 'wgCategoryCollation', 'uppercase' ); + + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addCategory( "Foo", "FOO" ); + + $this->assertLinksUpdate( $t, $po, 'categorylinks', 'cl_to, cl_sortkey', 'cl_from = 111', array( + array( 'Foo', "FOO\nTESTING" ), + ) ); + } + + /** + * @covers ParserOutput::addInterwikiLink + */ + public function testUpdate_iwlinks() { + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $target = Title::makeTitleSafe( NS_MAIN, "Foo", '', 'linksupdatetest' ); + $po->addInterwikiLink( $target ); + + $this->assertLinksUpdate( $t, $po, 'iwlinks', 'iwl_prefix, iwl_title', 'iwl_from = 111', array( + array( 'linksupdatetest', 'Foo' ), + ) ); + } + + /** + * @covers ParserOutput::addTemplate + */ + public function testUpdate_templatelinks() { + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addTemplate( Title::newFromText( "Template:Foo" ), 23, 42 ); + + $this->assertLinksUpdate( + $t, + $po, + 'templatelinks', + 'tl_namespace, + tl_title', + 'tl_from = 111', + array( array( NS_TEMPLATE, 'Foo' ) ) + ); + } + + /** + * @covers ParserOutput::addImage + */ + public function testUpdate_imagelinks() { + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addImage( "Foo.png" ); + + $this->assertLinksUpdate( $t, $po, 'imagelinks', 'il_to', 'il_from = 111', array( + array( 'Foo.png' ), + ) ); + } + + /** + * @covers ParserOutput::addLanguageLink + */ + public function testUpdate_langlinks() { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $po->addLanguageLink( Title::newFromText( "en:Foo" )->getFullText() ); + + $this->assertLinksUpdate( $t, $po, 'langlinks', 'll_lang, ll_title', 'll_from = 111', array( + array( 'En', 'Foo' ), + ) ); + } + + /** + * @covers ParserOutput::setProperty + */ + public function testUpdate_page_props() { + global $wgPagePropsHaveSortkey; + + /** @var ParserOutput $po */ + list( $t, $po ) = $this->makeTitleAndParserOutput( "Testing", 111 ); + + $fields = array( 'pp_propname', 'pp_value' ); + $expected = array(); + + $po->setProperty( "bool", true ); + $expected[] = array( "bool", true ); + + $po->setProperty( "float", 4.0 + 1.0 / 4.0 ); + $expected[] = array( "float", 4.0 + 1.0 / 4.0 ); + + $po->setProperty( "int", -7 ); + $expected[] = array( "int", -7 ); + + $po->setProperty( "string", "33 bar" ); + $expected[] = array( "string", "33 bar" ); + + // compute expected sortkey values + if ( $wgPagePropsHaveSortkey ) { + $fields[] = 'pp_sortkey'; + + foreach ( $expected as &$row ) { + $value = $row[1]; + + if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) { + $row[] = floatval( $value ); + } else { + $row[] = null; + } + } + } + + $this->assertLinksUpdate( $t, $po, 'page_props', $fields, 'pp_page = 111', $expected ); + } + + public function testUpdate_page_props_without_sortkey() { + $this->setMwGlobals( 'wgPagePropsHaveSortkey', false ); + + $this->testUpdate_page_props(); + } + + // @todo test recursive, too! + + protected function assertLinksUpdate( Title $title, ParserOutput $parserOutput, + $table, $fields, $condition, array $expectedRows + ) { + $update = new LinksUpdate( $title, $parserOutput ); + + //NOTE: make sure LinksUpdate does not generate warnings when called inside a transaction. + $update->beginTransaction(); + $update->doUpdate(); + $update->commitTransaction(); + + $this->assertSelect( $table, $fields, $condition, $expectedRows ); + return $update; + } +} diff --git a/tests/phpunit/includes/LocalFileTest.php b/tests/phpunit/includes/LocalFileTest.php new file mode 100644 index 00000000..5c5052e4 --- /dev/null +++ b/tests/phpunit/includes/LocalFileTest.php @@ -0,0 +1,184 @@ +<?php + +/** + * These tests should work regardless of $wgCapitalLinks + * @group Database + * @todo Split tests into providers and test methods + */ + +class LocalFileTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( 'wgCapitalLinks', true ); + + $info = array( + 'name' => 'test', + 'directory' => '/testdir', + 'url' => '/testurl', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => new FSFileBackend( array( + 'name' => 'local-backend', + 'wikiId' => wfWikiId(), + 'containerPaths' => array( + 'cont1' => "/testdir/local-backend/tempimages/cont1", + 'cont2' => "/testdir/local-backend/tempimages/cont2" + ) + ) ) + ); + $this->repo_hl0 = new LocalRepo( array( 'hashLevels' => 0 ) + $info ); + $this->repo_hl2 = new LocalRepo( array( 'hashLevels' => 2 ) + $info ); + $this->repo_lc = new LocalRepo( array( 'initialCapital' => false ) + $info ); + $this->file_hl0 = $this->repo_hl0->newFile( 'test!' ); + $this->file_hl2 = $this->repo_hl2->newFile( 'test!' ); + $this->file_lc = $this->repo_lc->newFile( 'test!' ); + } + + /** + * @covers File::getHashPath + */ + public function testGetHashPath() { + $this->assertEquals( '', $this->file_hl0->getHashPath() ); + $this->assertEquals( 'a/a2/', $this->file_hl2->getHashPath() ); + $this->assertEquals( 'c/c4/', $this->file_lc->getHashPath() ); + } + + /** + * @covers File::getRel + */ + public function testGetRel() { + $this->assertEquals( 'Test!', $this->file_hl0->getRel() ); + $this->assertEquals( 'a/a2/Test!', $this->file_hl2->getRel() ); + $this->assertEquals( 'c/c4/test!', $this->file_lc->getRel() ); + } + + /** + * @covers File::getUrlRel + */ + public function testGetUrlRel() { + $this->assertEquals( 'Test%21', $this->file_hl0->getUrlRel() ); + $this->assertEquals( 'a/a2/Test%21', $this->file_hl2->getUrlRel() ); + $this->assertEquals( 'c/c4/test%21', $this->file_lc->getUrlRel() ); + } + + /** + * @covers File::getArchivePath + */ + public function testGetArchivePath() { + $this->assertEquals( + 'mwstore://local-backend/test-public/archive', + $this->file_hl0->getArchivePath() + ); + $this->assertEquals( + 'mwstore://local-backend/test-public/archive/a/a2', + $this->file_hl2->getArchivePath() + ); + $this->assertEquals( + 'mwstore://local-backend/test-public/archive/!', + $this->file_hl0->getArchivePath( '!' ) + ); + $this->assertEquals( + 'mwstore://local-backend/test-public/archive/a/a2/!', + $this->file_hl2->getArchivePath( '!' ) + ); + } + + /** + * @covers File::getThumbPath + */ + public function testGetThumbPath() { + $this->assertEquals( + 'mwstore://local-backend/test-thumb/Test!', + $this->file_hl0->getThumbPath() + ); + $this->assertEquals( + 'mwstore://local-backend/test-thumb/a/a2/Test!', + $this->file_hl2->getThumbPath() + ); + $this->assertEquals( + 'mwstore://local-backend/test-thumb/Test!/x', + $this->file_hl0->getThumbPath( 'x' ) + ); + $this->assertEquals( + 'mwstore://local-backend/test-thumb/a/a2/Test!/x', + $this->file_hl2->getThumbPath( 'x' ) + ); + } + + /** + * @covers File::getArchiveUrl + */ + public function testGetArchiveUrl() { + $this->assertEquals( '/testurl/archive', $this->file_hl0->getArchiveUrl() ); + $this->assertEquals( '/testurl/archive/a/a2', $this->file_hl2->getArchiveUrl() ); + $this->assertEquals( '/testurl/archive/%21', $this->file_hl0->getArchiveUrl( '!' ) ); + $this->assertEquals( '/testurl/archive/a/a2/%21', $this->file_hl2->getArchiveUrl( '!' ) ); + } + + /** + * @covers File::getThumbUrl + */ + public function testGetThumbUrl() { + $this->assertEquals( '/testurl/thumb/Test%21', $this->file_hl0->getThumbUrl() ); + $this->assertEquals( '/testurl/thumb/a/a2/Test%21', $this->file_hl2->getThumbUrl() ); + $this->assertEquals( '/testurl/thumb/Test%21/x', $this->file_hl0->getThumbUrl( 'x' ) ); + $this->assertEquals( '/testurl/thumb/a/a2/Test%21/x', $this->file_hl2->getThumbUrl( 'x' ) ); + } + + /** + * @covers File::getArchiveVirtualUrl + */ + public function testGetArchiveVirtualUrl() { + $this->assertEquals( 'mwrepo://test/public/archive', $this->file_hl0->getArchiveVirtualUrl() ); + $this->assertEquals( + 'mwrepo://test/public/archive/a/a2', + $this->file_hl2->getArchiveVirtualUrl() + ); + $this->assertEquals( + 'mwrepo://test/public/archive/%21', + $this->file_hl0->getArchiveVirtualUrl( '!' ) + ); + $this->assertEquals( + 'mwrepo://test/public/archive/a/a2/%21', + $this->file_hl2->getArchiveVirtualUrl( '!' ) + ); + } + + /** + * @covers File::getThumbVirtualUrl + */ + public function testGetThumbVirtualUrl() { + $this->assertEquals( 'mwrepo://test/thumb/Test%21', $this->file_hl0->getThumbVirtualUrl() ); + $this->assertEquals( 'mwrepo://test/thumb/a/a2/Test%21', $this->file_hl2->getThumbVirtualUrl() ); + $this->assertEquals( + 'mwrepo://test/thumb/Test%21/%21', + $this->file_hl0->getThumbVirtualUrl( '!' ) + ); + $this->assertEquals( + 'mwrepo://test/thumb/a/a2/Test%21/%21', + $this->file_hl2->getThumbVirtualUrl( '!' ) + ); + } + + /** + * @covers File::getUrl + */ + public function testGetUrl() { + $this->assertEquals( '/testurl/Test%21', $this->file_hl0->getUrl() ); + $this->assertEquals( '/testurl/a/a2/Test%21', $this->file_hl2->getUrl() ); + } + + /** + * @covers ::wfLocalFile + */ + public function testWfLocalFile() { + $file = wfLocalFile( "File:Some_file_that_probably_doesn't exist.png" ); + $this->assertThat( + $file, + $this->isInstanceOf( 'LocalFile' ), + 'wfLocalFile() returns LocalFile for valid Titles' + ); + } +} diff --git a/tests/phpunit/includes/MWFunctionTest.php b/tests/phpunit/includes/MWFunctionTest.php new file mode 100644 index 00000000..f2a720e8 --- /dev/null +++ b/tests/phpunit/includes/MWFunctionTest.php @@ -0,0 +1,33 @@ +<?php + +/** + * @covers MWFunction + */ +class MWFunctionTest extends MediaWikiTestCase { + public function testNewObjFunction() { + $arg1 = 'Foo'; + $arg2 = 'Bar'; + $arg3 = array( 'Baz' ); + $arg4 = new ExampleObject; + + $args = array( $arg1, $arg2, $arg3, $arg4 ); + + $newObject = new MWBlankClass( $arg1, $arg2, $arg3, $arg4 ); + $this->assertEquals( + MWFunction::newObj( 'MWBlankClass', $args )->args, + $newObject->args + ); + } +} + +class MWBlankClass { + + public $args = array(); + + function __construct( $arg1, $arg2, $arg3, $arg4 ) { + $this->args = array( $arg1, $arg2, $arg3, $arg4 ); + } +} + +class ExampleObject { +} diff --git a/tests/phpunit/includes/MWNamespaceTest.php b/tests/phpunit/includes/MWNamespaceTest.php new file mode 100644 index 00000000..311350b5 --- /dev/null +++ b/tests/phpunit/includes/MWNamespaceTest.php @@ -0,0 +1,612 @@ +<?php +/** + * @author Antoine Musso + * @copyright Copyright © 2011, Antoine Musso + * @file + */ + +/** + * Test class for MWNamespace. + * Generated by PHPUnit on 2011-02-20 at 21:01:55. + * @todo covers tags + * @todo FIXME: this test file is a mess + * + */ +class MWNamespaceTest extends MediaWikiTestCase { + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgContentNamespaces' => array( NS_MAIN ), + 'wgNamespacesWithSubpages' => array( + NS_TALK => true, + NS_USER => true, + NS_USER_TALK => true, + ), + 'wgCapitalLinks' => true, + 'wgCapitalLinkOverrides' => array(), + 'wgNonincludableNamespaces' => array(), + ) ); + } + +#### START OF TESTS ######################################################### + + /** + * @todo Write more texts, handle $wgAllowImageMoving setting + * @covers MWNamespace::isMovable + */ + public function testIsMovable() { + $this->assertFalse( MWNamespace::isMovable( NS_SPECIAL ) ); + # @todo FIXME: Write more tests!! + } + + /** + * Please make sure to change testIsTalk() if you change the assertions below + * @covers MWNamespace::isSubject + */ + public function testIsSubject() { + // Special namespaces + $this->assertIsSubject( NS_MEDIA ); + $this->assertIsSubject( NS_SPECIAL ); + + // Subject pages + $this->assertIsSubject( NS_MAIN ); + $this->assertIsSubject( NS_USER ); + $this->assertIsSubject( 100 ); # user defined + + // Talk pages + $this->assertIsNotSubject( NS_TALK ); + $this->assertIsNotSubject( NS_USER_TALK ); + $this->assertIsNotSubject( 101 ); # user defined + } + + /** + * Reverse of testIsSubject(). + * Please update testIsSubject() if you change assertions below + * @covers MWNamespace::isTalk + */ + public function testIsTalk() { + // Special namespaces + $this->assertIsNotTalk( NS_MEDIA ); + $this->assertIsNotTalk( NS_SPECIAL ); + + // Subject pages + $this->assertIsNotTalk( NS_MAIN ); + $this->assertIsNotTalk( NS_USER ); + $this->assertIsNotTalk( 100 ); # user defined + + // Talk pages + $this->assertIsTalk( NS_TALK ); + $this->assertIsTalk( NS_USER_TALK ); + $this->assertIsTalk( 101 ); # user defined + } + + /** + * @covers MWNamespace::getSubject + */ + public function testGetSubject() { + // Special namespaces are their own subjects + $this->assertEquals( NS_MEDIA, MWNamespace::getSubject( NS_MEDIA ) ); + $this->assertEquals( NS_SPECIAL, MWNamespace::getSubject( NS_SPECIAL ) ); + + $this->assertEquals( NS_MAIN, MWNamespace::getSubject( NS_TALK ) ); + $this->assertEquals( NS_USER, MWNamespace::getSubject( NS_USER_TALK ) ); + } + + /** + * Regular getTalk() calls + * Namespaces without a talk page (NS_MEDIA, NS_SPECIAL) are tested in + * the function testGetTalkExceptions() + * @covers MWNamespace::getTalk + */ + public function testGetTalk() { + $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_MAIN ) ); + $this->assertEquals( NS_TALK, MWNamespace::getTalk( NS_TALK ) ); + $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER ) ); + $this->assertEquals( NS_USER_TALK, MWNamespace::getTalk( NS_USER_TALK ) ); + } + + /** + * Exceptions with getTalk() + * NS_MEDIA does not have talk pages. MediaWiki raise an exception for them. + * @expectedException MWException + * @covers MWNamespace::getTalk + */ + public function testGetTalkExceptionsForNsMedia() { + $this->assertNull( MWNamespace::getTalk( NS_MEDIA ) ); + } + + /** + * Exceptions with getTalk() + * NS_SPECIAL does not have talk pages. MediaWiki raise an exception for them. + * @expectedException MWException + * @covers MWNamespace::getTalk + */ + public function testGetTalkExceptionsForNsSpecial() { + $this->assertNull( MWNamespace::getTalk( NS_SPECIAL ) ); + } + + /** + * Regular getAssociated() calls + * Namespaces without an associated page (NS_MEDIA, NS_SPECIAL) are tested in + * the function testGetAssociatedExceptions() + * @covers MWNamespace::getAssociated + */ + public function testGetAssociated() { + $this->assertEquals( NS_TALK, MWNamespace::getAssociated( NS_MAIN ) ); + $this->assertEquals( NS_MAIN, MWNamespace::getAssociated( NS_TALK ) ); + } + + ### Exceptions with getAssociated() + ### NS_MEDIA and NS_SPECIAL do not have talk pages. MediaWiki raises + ### an exception for them. + /** + * @expectedException MWException + * @covers MWNamespace::getAssociated + */ + public function testGetAssociatedExceptionsForNsMedia() { + $this->assertNull( MWNamespace::getAssociated( NS_MEDIA ) ); + } + + /** + * @expectedException MWException + * @covers MWNamespace::getAssociated + */ + public function testGetAssociatedExceptionsForNsSpecial() { + $this->assertNull( MWNamespace::getAssociated( NS_SPECIAL ) ); + } + + /** + * @todo Implement testExists(). + */ + /* + public function testExists() { + // Remove the following lines when you implement this test. + $this->markTestIncomplete( + 'This test has not been implemented yet. Rely on $wgCanonicalNamespaces.' + ); + } + */ + + /** + * Test MWNamespace::equals + * Note if we add a namespace registration system with keys like 'MAIN' + * we should add tests here for equivilance on things like 'MAIN' == 0 + * and 'MAIN' == NS_MAIN. + * @covers MWNamespace::equals + */ + public function testEquals() { + $this->assertTrue( MWNamespace::equals( NS_MAIN, NS_MAIN ) ); + $this->assertTrue( MWNamespace::equals( NS_MAIN, 0 ) ); // In case we make NS_MAIN 'MAIN' + $this->assertTrue( MWNamespace::equals( NS_USER, NS_USER ) ); + $this->assertTrue( MWNamespace::equals( NS_USER, 2 ) ); + $this->assertTrue( MWNamespace::equals( NS_USER_TALK, NS_USER_TALK ) ); + $this->assertTrue( MWNamespace::equals( NS_SPECIAL, NS_SPECIAL ) ); + $this->assertFalse( MWNamespace::equals( NS_MAIN, NS_TALK ) ); + $this->assertFalse( MWNamespace::equals( NS_USER, NS_USER_TALK ) ); + $this->assertFalse( MWNamespace::equals( NS_PROJECT, NS_TEMPLATE ) ); + } + + /** + * @covers MWNamespace::subjectEquals + */ + public function testSubjectEquals() { + $this->assertSameSubject( NS_MAIN, NS_MAIN ); + $this->assertSameSubject( NS_MAIN, 0 ); // In case we make NS_MAIN 'MAIN' + $this->assertSameSubject( NS_USER, NS_USER ); + $this->assertSameSubject( NS_USER, 2 ); + $this->assertSameSubject( NS_USER_TALK, NS_USER_TALK ); + $this->assertSameSubject( NS_SPECIAL, NS_SPECIAL ); + $this->assertSameSubject( NS_MAIN, NS_TALK ); + $this->assertSameSubject( NS_USER, NS_USER_TALK ); + + $this->assertDifferentSubject( NS_PROJECT, NS_TEMPLATE ); + $this->assertDifferentSubject( NS_SPECIAL, NS_MAIN ); + } + + /** + * @covers MWNamespace::subjectEquals + */ + public function testSpecialAndMediaAreDifferentSubjects() { + $this->assertDifferentSubject( + NS_MEDIA, NS_SPECIAL, + "NS_MEDIA and NS_SPECIAL are different subject namespaces" + ); + $this->assertDifferentSubject( + NS_SPECIAL, NS_MEDIA, + "NS_SPECIAL and NS_MEDIA are different subject namespaces" + ); + } + + /** + * @todo Implement testGetCanonicalNamespaces(). + */ + /* + public function testGetCanonicalNamespaces() { + // Remove the following lines when you implement this test. + $this->markTestIncomplete( + 'This test has not been implemented yet. Rely on $wgCanonicalNamespaces.' + ); + } + */ + /** + * @todo Implement testGetCanonicalName(). + */ + /* + public function testGetCanonicalName() { + // Remove the following lines when you implement this test. + $this->markTestIncomplete( + 'This test has not been implemented yet. Rely on $wgCanonicalNamespaces.' + ); + } + */ + /** + * @todo Implement testGetCanonicalIndex(). + */ + /* + public function testGetCanonicalIndex() { + // Remove the following lines when you implement this test. + $this->markTestIncomplete( + 'This test has not been implemented yet. Rely on $wgCanonicalNamespaces.' + ); + } + */ + + /** + * @todo Implement testGetValidNamespaces(). + */ + /* + public function testGetValidNamespaces() { + // Remove the following lines when you implement this test. + $this->markTestIncomplete( + 'This test has not been implemented yet. Rely on $wgCanonicalNamespaces.' + ); + } + */ + + /** + * @covers MWNamespace::canTalk + */ + public function testCanTalk() { + $this->assertCanNotTalk( NS_MEDIA ); + $this->assertCanNotTalk( NS_SPECIAL ); + + $this->assertCanTalk( NS_MAIN ); + $this->assertCanTalk( NS_TALK ); + $this->assertCanTalk( NS_USER ); + $this->assertCanTalk( NS_USER_TALK ); + + // User defined namespaces + $this->assertCanTalk( 100 ); + $this->assertCanTalk( 101 ); + } + + /** + * @covers MWNamespace::isContent + */ + public function testIsContent() { + // NS_MAIN is a content namespace per DefaultSettings.php + // and per function definition. + + $this->assertIsContent( NS_MAIN ); + + // Other namespaces which are not expected to be content + + $this->assertIsNotContent( NS_MEDIA ); + $this->assertIsNotContent( NS_SPECIAL ); + $this->assertIsNotContent( NS_TALK ); + $this->assertIsNotContent( NS_USER ); + $this->assertIsNotContent( NS_CATEGORY ); + $this->assertIsNotContent( 100 ); + } + + /** + * Similar to testIsContent() but alters the $wgContentNamespaces + * global variable. + * @covers MWNamespace::isContent + */ + public function testIsContentAdvanced() { + global $wgContentNamespaces; + + // Test that user defined namespace #252 is not content + $this->assertIsNotContent( 252 ); + + // Bless namespace # 252 as a content namespace + $wgContentNamespaces[] = 252; + + $this->assertIsContent( 252 ); + + // Makes sure NS_MAIN was not impacted + $this->assertIsContent( NS_MAIN ); + } + + /** + * @covers MWNamespace::isWatchable + */ + public function testIsWatchable() { + // Specials namespaces are not watchable + $this->assertIsNotWatchable( NS_MEDIA ); + $this->assertIsNotWatchable( NS_SPECIAL ); + + // Core defined namespaces are watchables + $this->assertIsWatchable( NS_MAIN ); + $this->assertIsWatchable( NS_TALK ); + + // Additional, user defined namespaces are watchables + $this->assertIsWatchable( 100 ); + $this->assertIsWatchable( 101 ); + } + + /** + * @covers MWNamespace::hasSubpages + */ + public function testHasSubpages() { + global $wgNamespacesWithSubpages; + + // Special namespaces: + $this->assertHasNotSubpages( NS_MEDIA ); + $this->assertHasNotSubpages( NS_SPECIAL ); + + // Namespaces without subpages + $this->assertHasNotSubpages( NS_MAIN ); + + $wgNamespacesWithSubpages[NS_MAIN] = true; + $this->assertHasSubpages( NS_MAIN ); + + $wgNamespacesWithSubpages[NS_MAIN] = false; + $this->assertHasNotSubpages( NS_MAIN ); + + // Some namespaces with subpages + $this->assertHasSubpages( NS_TALK ); + $this->assertHasSubpages( NS_USER ); + $this->assertHasSubpages( NS_USER_TALK ); + } + + /** + * @covers MWNamespace::getContentNamespaces + */ + public function testGetContentNamespaces() { + global $wgContentNamespaces; + + $this->assertEquals( + array( NS_MAIN ), + MWNamespace::getContentNamespaces(), + '$wgContentNamespaces is an array with only NS_MAIN by default' + ); + + # test !is_array( $wgcontentNamespaces ) + $wgContentNamespaces = ''; + $this->assertEquals( array( NS_MAIN ), MWNamespace::getContentNamespaces() ); + + $wgContentNamespaces = false; + $this->assertEquals( array( NS_MAIN ), MWNamespace::getContentNamespaces() ); + + $wgContentNamespaces = null; + $this->assertEquals( array( NS_MAIN ), MWNamespace::getContentNamespaces() ); + + $wgContentNamespaces = 5; + $this->assertEquals( array( NS_MAIN ), MWNamespace::getContentNamespaces() ); + + # test $wgContentNamespaces === array() + $wgContentNamespaces = array(); + $this->assertEquals( array( NS_MAIN ), MWNamespace::getContentNamespaces() ); + + # test !in_array( NS_MAIN, $wgContentNamespaces ) + $wgContentNamespaces = array( NS_USER, NS_CATEGORY ); + $this->assertEquals( + array( NS_MAIN, NS_USER, NS_CATEGORY ), + MWNamespace::getContentNamespaces(), + 'NS_MAIN is forced in $wgContentNamespaces even if unwanted' + ); + + # test other cases, return $wgcontentNamespaces as is + $wgContentNamespaces = array( NS_MAIN ); + $this->assertEquals( + array( NS_MAIN ), + MWNamespace::getContentNamespaces() + ); + + $wgContentNamespaces = array( NS_MAIN, NS_USER, NS_CATEGORY ); + $this->assertEquals( + array( NS_MAIN, NS_USER, NS_CATEGORY ), + MWNamespace::getContentNamespaces() + ); + } + + /** + * @covers MWNamespace::getSubjectNamespaces + */ + public function testGetSubjectNamespaces() { + $subjectsNS = MWNamespace::getSubjectNamespaces(); + $this->assertContains( NS_MAIN, $subjectsNS, + "Talk namespaces should have NS_MAIN" ); + $this->assertNotContains( NS_TALK, $subjectsNS, + "Talk namespaces should have NS_TALK" ); + + $this->assertNotContains( NS_MEDIA, $subjectsNS, + "Talk namespaces should not have NS_MEDIA" ); + $this->assertNotContains( NS_SPECIAL, $subjectsNS, + "Talk namespaces should not have NS_SPECIAL" ); + } + + /** + * @covers MWNamespace::getTalkNamespaces + */ + public function testGetTalkNamespaces() { + $talkNS = MWNamespace::getTalkNamespaces(); + $this->assertContains( NS_TALK, $talkNS, + "Subject namespaces should have NS_TALK" ); + $this->assertNotContains( NS_MAIN, $talkNS, + "Subject namespaces should not have NS_MAIN" ); + + $this->assertNotContains( NS_MEDIA, $talkNS, + "Subject namespaces should not have NS_MEDIA" ); + $this->assertNotContains( NS_SPECIAL, $talkNS, + "Subject namespaces should not have NS_SPECIAL" ); + } + + /** + * Some namespaces are always capitalized per code definition + * in MWNamespace::$alwaysCapitalizedNamespaces + * @covers MWNamespace::isCapitalized + */ + public function testIsCapitalizedHardcodedAssertions() { + // NS_MEDIA and NS_FILE are treated the same + $this->assertEquals( + MWNamespace::isCapitalized( NS_MEDIA ), + MWNamespace::isCapitalized( NS_FILE ), + 'NS_MEDIA and NS_FILE have same capitalization rendering' + ); + + // Boths are capitalized by default + $this->assertIsCapitalized( NS_MEDIA ); + $this->assertIsCapitalized( NS_FILE ); + + // Always capitalized namespaces + // @see MWNamespace::$alwaysCapitalizedNamespaces + $this->assertIsCapitalized( NS_SPECIAL ); + $this->assertIsCapitalized( NS_USER ); + $this->assertIsCapitalized( NS_MEDIAWIKI ); + } + + /** + * Follows up for testIsCapitalizedHardcodedAssertions() but alter the + * global $wgCapitalLink setting to have extended coverage. + * + * MWNamespace::isCapitalized() rely on two global settings: + * $wgCapitalLinkOverrides = array(); by default + * $wgCapitalLinks = true; by default + * This function test $wgCapitalLinks + * + * Global setting correctness is tested against the NS_PROJECT and + * NS_PROJECT_TALK namespaces since they are not hardcoded nor specials + * @covers MWNamespace::isCapitalized + */ + public function testIsCapitalizedWithWgCapitalLinks() { + global $wgCapitalLinks; + + $this->assertIsCapitalized( NS_PROJECT ); + $this->assertIsCapitalized( NS_PROJECT_TALK ); + + $wgCapitalLinks = false; + + // hardcoded namespaces (see above function) are still capitalized: + $this->assertIsCapitalized( NS_SPECIAL ); + $this->assertIsCapitalized( NS_USER ); + $this->assertIsCapitalized( NS_MEDIAWIKI ); + + // setting is correctly applied + $this->assertIsNotCapitalized( NS_PROJECT ); + $this->assertIsNotCapitalized( NS_PROJECT_TALK ); + } + + /** + * Counter part for MWNamespace::testIsCapitalizedWithWgCapitalLinks() now + * testing the $wgCapitalLinkOverrides global. + * + * @todo split groups of assertions in autonomous testing functions + * @covers MWNamespace::isCapitalized + */ + public function testIsCapitalizedWithWgCapitalLinkOverrides() { + global $wgCapitalLinkOverrides; + + // Test default settings + $this->assertIsCapitalized( NS_PROJECT ); + $this->assertIsCapitalized( NS_PROJECT_TALK ); + + // hardcoded namespaces (see above function) are capitalized: + $this->assertIsCapitalized( NS_SPECIAL ); + $this->assertIsCapitalized( NS_USER ); + $this->assertIsCapitalized( NS_MEDIAWIKI ); + + // Hardcoded namespaces remains capitalized + $wgCapitalLinkOverrides[NS_SPECIAL] = false; + $wgCapitalLinkOverrides[NS_USER] = false; + $wgCapitalLinkOverrides[NS_MEDIAWIKI] = false; + + $this->assertIsCapitalized( NS_SPECIAL ); + $this->assertIsCapitalized( NS_USER ); + $this->assertIsCapitalized( NS_MEDIAWIKI ); + + $wgCapitalLinkOverrides[NS_PROJECT] = false; + $this->assertIsNotCapitalized( NS_PROJECT ); + + $wgCapitalLinkOverrides[NS_PROJECT] = true; + $this->assertIsCapitalized( NS_PROJECT ); + + unset( $wgCapitalLinkOverrides[NS_PROJECT] ); + $this->assertIsCapitalized( NS_PROJECT ); + } + + /** + * @covers MWNamespace::hasGenderDistinction + */ + public function testHasGenderDistinction() { + // Namespaces with gender distinctions + $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER ) ); + $this->assertTrue( MWNamespace::hasGenderDistinction( NS_USER_TALK ) ); + + // Other ones, "genderless" + $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MEDIA ) ); + $this->assertFalse( MWNamespace::hasGenderDistinction( NS_SPECIAL ) ); + $this->assertFalse( MWNamespace::hasGenderDistinction( NS_MAIN ) ); + $this->assertFalse( MWNamespace::hasGenderDistinction( NS_TALK ) ); + } + + /** + * @covers MWNamespace::isNonincludable + */ + public function testIsNonincludable() { + global $wgNonincludableNamespaces; + + $wgNonincludableNamespaces = array( NS_USER ); + + $this->assertTrue( MWNamespace::isNonincludable( NS_USER ) ); + $this->assertFalse( MWNamespace::isNonincludable( NS_TEMPLATE ) ); + } + + ####### HELPERS ########################################################### + function __call( $method, $args ) { + // Call the real method if it exists + if ( method_exists( $this, $method ) ) { + return $this->$method( $args ); + } + + if ( preg_match( + '/^assert(Has|Is|Can)(Not|)(Subject|Talk|Watchable|Content|Subpages|Capitalized)$/', + $method, + $m + ) ) { + # Interprets arguments: + $ns = $args[0]; + $msg = isset( $args[1] ) ? $args[1] : " dummy message"; + + # Forge the namespace constant name: + if ( $ns === 0 ) { + $ns_name = "NS_MAIN"; + } else { + $ns_name = "NS_" . strtoupper( MWNamespace::getCanonicalName( $ns ) ); + } + # ... and the MWNamespace method name + $nsMethod = strtolower( $m[1] ) . $m[3]; + + $expect = ( $m[2] === '' ); + $expect_name = $expect ? 'TRUE' : 'FALSE'; + + return $this->assertEquals( $expect, + MWNamespace::$nsMethod( $ns, $msg ), + "MWNamespace::$nsMethod( $ns_name ) should returns $expect_name" + ); + } + + throw new Exception( __METHOD__ . " could not find a method named $method\n" ); + } + + function assertSameSubject( $ns1, $ns2, $msg = '' ) { + $this->assertTrue( MWNamespace::subjectEquals( $ns1, $ns2, $msg ) ); + } + + function assertDifferentSubject( $ns1, $ns2, $msg = '' ) { + $this->assertFalse( MWNamespace::subjectEquals( $ns1, $ns2, $msg ) ); + } +} diff --git a/tests/phpunit/includes/MWTimestampTest.php b/tests/phpunit/includes/MWTimestampTest.php new file mode 100644 index 00000000..dcb98563 --- /dev/null +++ b/tests/phpunit/includes/MWTimestampTest.php @@ -0,0 +1,342 @@ +<?php + +/** + * Tests timestamp parsing and output. + */ +class MWTimestampTest extends MediaWikiLangTestCase { + + protected function setUp() { + parent::setUp(); + + RequestContext::getMain()->setLanguage( Language::factory( 'en' ) ); + } + + /** + * @covers MWTimestamp::__construct + */ + public function testConstructWithNoTimestamp() { + $timestamp = new MWTimestamp(); + $this->assertInternalType( 'string', $timestamp->getTimestamp() ); + $this->assertNotEmpty( $timestamp->getTimestamp() ); + $this->assertNotEquals( false, strtotime( $timestamp->getTimestamp( TS_MW ) ) ); + } + + /** + * @covers MWTimestamp::__toString + */ + public function testToString() { + $timestamp = new MWTimestamp( '1406833268' ); // Equivalent to 20140731190108 + $this->assertEquals( '1406833268', $timestamp->__toString() ); + } + + public static function provideValidTimestampDifferences() { + return array( + array( '1406833268', '1406833269', '00 00 00 01' ), + array( '1406833268', '1406833329', '00 00 01 01' ), + array( '1406833268', '1406836929', '00 01 01 01' ), + array( '1406833268', '1406923329', '01 01 01 01' ), + ); + } + + /** + * @dataProvider provideValidTimestampDifferences + * @covers MWTimestamp::diff + */ + public function testDiff( $timestamp1, $timestamp2, $expected ) { + $timestamp1 = new MWTimestamp( $timestamp1 ); + $timestamp2 = new MWTimestamp( $timestamp2 ); + $diff = $timestamp1->diff( $timestamp2 ); + $this->assertEquals( $expected, $diff->format( '%D %H %I %S' ) ); + } + + /** + * Test parsing of valid timestamps and outputing to MW format. + * @dataProvider provideValidTimestamps + * @covers MWTimestamp::getTimestamp + */ + public function testValidParse( $format, $original, $expected ) { + $timestamp = new MWTimestamp( $original ); + $this->assertEquals( $expected, $timestamp->getTimestamp( TS_MW ) ); + } + + /** + * Test outputting valid timestamps to different formats. + * @dataProvider provideValidTimestamps + * @covers MWTimestamp::getTimestamp + */ + public function testValidOutput( $format, $expected, $original ) { + $timestamp = new MWTimestamp( $original ); + $this->assertEquals( $expected, (string)$timestamp->getTimestamp( $format ) ); + } + + /** + * Test an invalid timestamp. + * @expectedException TimestampException + * @covers MWTimestamp + */ + public function testInvalidParse() { + new MWTimestamp( "This is not a timestamp." ); + } + + /** + * Test requesting an invalid output format. + * @expectedException TimestampException + * @covers MWTimestamp::getTimestamp + */ + public function testInvalidOutput() { + $timestamp = new MWTimestamp( '1343761268' ); + $timestamp->getTimestamp( 98 ); + } + + /** + * Returns a list of valid timestamps in the format: + * array( type, timestamp_of_type, timestamp_in_MW ) + */ + public static function provideValidTimestamps() { + return array( + // Various formats + array( TS_UNIX, '1343761268', '20120731190108' ), + array( TS_MW, '20120731190108', '20120731190108' ), + array( TS_DB, '2012-07-31 19:01:08', '20120731190108' ), + array( TS_ISO_8601, '2012-07-31T19:01:08Z', '20120731190108' ), + array( TS_ISO_8601_BASIC, '20120731T190108Z', '20120731190108' ), + array( TS_EXIF, '2012:07:31 19:01:08', '20120731190108' ), + array( TS_RFC2822, 'Tue, 31 Jul 2012 19:01:08 GMT', '20120731190108' ), + array( TS_ORACLE, '31-07-2012 19:01:08.000000', '20120731190108' ), + array( TS_POSTGRES, '2012-07-31 19:01:08 GMT', '20120731190108' ), + // Some extremes and weird values + array( TS_ISO_8601, '9999-12-31T23:59:59Z', '99991231235959' ), + array( TS_UNIX, '-62135596801', '00001231235959' ) + ); + } + + /** + * @dataProvider provideHumanTimestampTests + * @covers MWTimestamp::getHumanTimestamp + */ + public function testHumanTimestamp( + $tsTime, // The timestamp to format + $currentTime, // The time to consider "now" + $timeCorrection, // The time offset to use + $dateFormat, // The date preference to use + $expectedOutput, // The expected output + $desc // Description + ) { + $user = $this->getMock( 'User' ); + $user->expects( $this->any() ) + ->method( 'getOption' ) + ->with( 'timecorrection' ) + ->will( $this->returnValue( $timeCorrection ) ); + + $user->expects( $this->any() ) + ->method( 'getDatePreference' ) + ->will( $this->returnValue( $dateFormat ) ); + + $tsTime = new MWTimestamp( $tsTime ); + $currentTime = new MWTimestamp( $currentTime ); + + $this->assertEquals( + $expectedOutput, + $tsTime->getHumanTimestamp( $currentTime, $user ), + $desc + ); + } + + public static function provideHumanTimestampTests() { + return array( + array( + '20111231170000', + '20120101000000', + 'Offset|0', + 'mdy', + 'Yesterday at 17:00', + '"Yesterday" across years', + ), + array( + '20120717190900', + '20120717190929', + 'Offset|0', + 'mdy', + 'just now', + '"Just now"', + ), + array( + '20120717190900', + '20120717191530', + 'Offset|0', + 'mdy', + '6 minutes ago', + 'X minutes ago', + ), + array( + '20121006173100', + '20121006173200', + 'Offset|0', + 'mdy', + '1 minute ago', + '"1 minute ago"', + ), + array( + '20120617190900', + '20120717190900', + 'Offset|0', + 'mdy', + 'June 17', + 'Another month' + ), + array( + '19910130151500', + '20120716193700', + 'Offset|0', + 'mdy', + '15:15, January 30, 1991', + 'Different year', + ), + array( + '20120101050000', + '20120101080000', + 'Offset|-360', + 'mdy', + 'Yesterday at 23:00', + '"Yesterday" across years with time correction', + ), + array( + '20120714184300', + '20120716184300', + 'Offset|-420', + 'mdy', + 'Saturday at 11:43', + 'Recent weekday with time correction', + ), + array( + '20120714184300', + '20120715040000', + 'Offset|-420', + 'mdy', + '11:43', + 'Today at another time with time correction', + ), + array( + '20120617190900', + '20120717190900', + 'Offset|0', + 'dmy', + '17 June', + 'Another month with dmy' + ), + array( + '20120617190900', + '20120717190900', + 'Offset|0', + 'ISO 8601', + '06-17', + 'Another month with ISO-8601' + ), + array( + '19910130151500', + '20120716193700', + 'Offset|0', + 'ISO 8601', + '1991-01-30T15:15:00', + 'Different year with ISO-8601', + ), + ); + } + + /** + * @dataProvider provideRelativeTimestampTests + * @covers MWTimestamp::getRelativeTimestamp + */ + public function testRelativeTimestamp( + $tsTime, // The timestamp to format + $currentTime, // The time to consider "now" + $timeCorrection, // The time offset to use + $dateFormat, // The date preference to use + $expectedOutput, // The expected output + $desc // Description + ) { + $user = $this->getMock( 'User' ); + $user->expects( $this->any() ) + ->method( 'getOption' ) + ->with( 'timecorrection' ) + ->will( $this->returnValue( $timeCorrection ) ); + + $tsTime = new MWTimestamp( $tsTime ); + $currentTime = new MWTimestamp( $currentTime ); + + $this->assertEquals( + $expectedOutput, + $tsTime->getRelativeTimestamp( $currentTime, $user ), + $desc + ); + } + + public static function provideRelativeTimestampTests() { + return array( + array( + '20111231170000', + '20120101000000', + 'Offset|0', + 'mdy', + '7 hours ago', + '"Yesterday" across years', + ), + array( + '20120717190900', + '20120717190929', + 'Offset|0', + 'mdy', + '29 seconds ago', + '"Just now"', + ), + array( + '20120717190900', + '20120717191530', + 'Offset|0', + 'mdy', + '6 minutes and 30 seconds ago', + 'Combination of multiple units', + ), + array( + '20121006173100', + '20121006173200', + 'Offset|0', + 'mdy', + '1 minute ago', + '"1 minute ago"', + ), + array( + '19910130151500', + '20120716193700', + 'Offset|0', + 'mdy', + '2 decades, 1 year, 168 days, 2 hours, 8 minutes and 48 seconds ago', + 'A long time ago', + ), + array( + '20120101050000', + '20120101080000', + 'Offset|-360', + 'mdy', + '3 hours ago', + '"Yesterday" across years with time correction', + ), + array( + '20120714184300', + '20120716184300', + 'Offset|-420', + 'mdy', + '2 days ago', + 'Recent weekday with time correction', + ), + array( + '20120714184300', + '20120715040000', + 'Offset|-420', + 'mdy', + '9 hours and 17 minutes ago', + 'Today at another time with time correction', + ), + ); + } +} diff --git a/tests/phpunit/includes/MediaWikiVersionFetcherTest.php b/tests/phpunit/includes/MediaWikiVersionFetcherTest.php new file mode 100644 index 00000000..e548f817 --- /dev/null +++ b/tests/phpunit/includes/MediaWikiVersionFetcherTest.php @@ -0,0 +1,21 @@ +<?php + +/** + * Note: this is not a unit test, as it touches the file system and reads an actual file. + * If unit tests are added for MediaWikiVersionFetcher, this should be done in a distinct test case. + * + * @covers MediaWikiVersionFetcher + * + * @group ComposerHooks + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class MediaWikiVersionFetcherTest extends PHPUnit_Framework_TestCase { + + public function testReturnsResult() { + $versionFetcher = new MediaWikiVersionFetcher(); + $this->assertInternalType( 'string', $versionFetcher->fetchVersion() ); + } + +} diff --git a/tests/phpunit/includes/MessageTest.php b/tests/phpunit/includes/MessageTest.php new file mode 100644 index 00000000..f3d2a84a --- /dev/null +++ b/tests/phpunit/includes/MessageTest.php @@ -0,0 +1,368 @@ +<?php + +class MessageTest extends MediaWikiLangTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgLang' => Language::factory( 'en' ), + 'wgForceUIMsgAsContentMsg' => array(), + ) ); + } + + /** + * @covers Message::__construct + * @dataProvider provideConstructor + */ + public function testConstructor( $expectedLang, $key, $params, $language ) { + $reflection = new ReflectionClass( 'Message' ); + + $keyProperty = $reflection->getProperty( 'key' ); + $keyProperty->setAccessible( true ); + + $paramsProperty = $reflection->getProperty( 'parameters' ); + $paramsProperty->setAccessible( true ); + + $langProperty = $reflection->getProperty( 'language' ); + $langProperty->setAccessible( true ); + + $message = new Message( $key, $params, $language ); + + $this->assertEquals( $key, $keyProperty->getValue( $message ) ); + $this->assertEquals( $params, $paramsProperty->getValue( $message ) ); + $this->assertEquals( $expectedLang, $langProperty->getValue( $message ) ); + } + + public static function provideConstructor() { + $langDe = Language::factory( 'de' ); + $langEn = Language::factory( 'en' ); + + return array( + array( $langDe, 'foo', array(), $langDe ), + array( $langDe, 'foo', array( 'bar' ), $langDe ), + array( $langEn, 'foo', array( 'bar' ), null ) + ); + } + + public static function provideTestParams() { + return array( + array( array() ), + array( array( 'foo' ), 'foo' ), + array( array( 'foo', 'bar' ), 'foo', 'bar' ), + array( array( 'baz' ), array( 'baz' ) ), + array( array( 'baz', 'foo' ), array( 'baz', 'foo' ) ), + array( array( 'baz', 'foo' ), array( 'baz', 'foo' ), 'hhh' ), + array( array( 'baz', 'foo' ), array( 'baz', 'foo' ), 'hhh', array( 'ahahahahha' ) ), + array( array( 'baz', 'foo' ), array( 'baz', 'foo' ), array( 'ahahahahha' ) ), + array( array( 'baz' ), array( 'baz' ), array( 'ahahahahha' ) ), + ); + } + + public function getLanguageProvider() { + return array( + array( 'foo', array( 'bar' ), 'en' ), + array( 'foo', array( 'bar' ), 'de' ) + ); + } + + /** + * @covers Message::getLanguage + * @dataProvider getLanguageProvider + */ + public function testGetLanguageCode( $key, $params, $languageCode ) { + $language = Language::factory( $languageCode ); + $message = new Message( $key, $params, $language ); + + $this->assertEquals( $language, $message->getLanguage() ); + } + + /** + * @covers Message::params + * @dataProvider provideTestParams + */ + public function testParams( $expected ) { + $msg = new Message( 'imasomething' ); + + $returned = call_user_func_array( array( $msg, 'params' ), array_slice( func_get_args(), 1 ) ); + + $this->assertSame( $msg, $returned ); + $this->assertEquals( $expected, $msg->getParams() ); + } + + /** + * @covers Message::exists + */ + public function testExists() { + $this->assertTrue( wfMessage( 'mainpage' )->exists() ); + $this->assertTrue( wfMessage( 'mainpage' )->params( array() )->exists() ); + $this->assertTrue( wfMessage( 'mainpage' )->rawParams( 'foo', 123 )->exists() ); + $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->exists() ); + $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->params( array() )->exists() ); + $this->assertFalse( wfMessage( 'i-dont-exist-evar' )->rawParams( 'foo', 123 )->exists() ); + } + + /** + * @covers Message::__construct + */ + public function testKey() { + $this->assertInstanceOf( 'Message', wfMessage( 'mainpage' ) ); + $this->assertInstanceOf( 'Message', wfMessage( 'i-dont-exist-evar' ) ); + $this->assertEquals( 'Main Page', wfMessage( 'mainpage' )->text() ); + $this->assertEquals( '<i-dont-exist-evar>', wfMessage( 'i-dont-exist-evar' )->text() ); + $this->assertEquals( '<i-dont-exist-evar>', wfMessage( 'i-dont-exist-evar' )->plain() ); + $this->assertEquals( '<i-dont-exist-evar>', wfMessage( 'i-dont-exist-evar' )->escaped() ); + } + + /** + * @covers Message::inLanguage + */ + public function testInLanguage() { + $this->assertEquals( 'Main Page', wfMessage( 'mainpage' )->inLanguage( 'en' )->text() ); + $this->assertEquals( 'Заглавная страница', + wfMessage( 'mainpage' )->inLanguage( 'ru' )->text() ); + + // NOTE: make sure internal caching of the message text is reset appropriately + $msg = wfMessage( 'mainpage' ); + $this->assertEquals( 'Main Page', $msg->inLanguage( Language::factory( 'en' ) )->text() ); + $this->assertEquals( + 'Заглавная страница', + $msg->inLanguage( Language::factory( 'ru' ) )->text() + ); + } + + /** + * @covers Message::__construct + */ + public function testMessageParams() { + $this->assertEquals( 'Return to $1.', wfMessage( 'returnto' )->text() ); + $this->assertEquals( 'Return to $1.', wfMessage( 'returnto', array() )->text() ); + $this->assertEquals( + 'You have foo (bar).', + wfMessage( 'youhavenewmessages', 'foo', 'bar' )->text() + ); + $this->assertEquals( + 'You have foo (bar).', + wfMessage( 'youhavenewmessages', array( 'foo', 'bar' ) )->text() + ); + } + + /** + * @covers Message::__construct + * @covers Message::rawParams + */ + public function testMessageParamSubstitution() { + $this->assertEquals( + '(Заглавная страница)', + wfMessage( 'parentheses', 'Заглавная страница' )->plain() + ); + $this->assertEquals( + '(Заглавная страница $1)', + wfMessage( 'parentheses', 'Заглавная страница $1' )->plain() + ); + $this->assertEquals( + '(Заглавная страница)', + wfMessage( 'parentheses' )->rawParams( 'Заглавная страница' )->plain() + ); + $this->assertEquals( + '(Заглавная страница $1)', + wfMessage( 'parentheses' )->rawParams( 'Заглавная страница $1' )->plain() + ); + } + + /** + * @covers Message::__construct + * @covers Message::params + */ + public function testDeliciouslyManyParams() { + $msg = new RawMessage( '$1$2$3$4$5$6$7$8$9$10$11$12' ); + // One less than above has placeholders + $params = array( 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k' ); + $this->assertEquals( + 'abcdefghijka2', + $msg->params( $params )->plain(), + 'Params > 9 are replaced correctly' + ); + } + + /** + * @covers Message::numParams + */ + public function testMessageNumParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatNum( 123456.789 ), + $msg->inLanguage( $lang )->numParams( 123456.789 )->plain(), + 'numParams is handled correctly' + ); + } + + /** + * @covers Message::durationParams + */ + public function testMessageDurationParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatDuration( 1234 ), + $msg->inLanguage( $lang )->durationParams( 1234 )->plain(), + 'durationParams is handled correctly' + ); + } + + /** + * FIXME: This should not need database, but Language#formatExpiry does (bug 55912) + * @group Database + * @covers Message::expiryParams + */ + public function testMessageExpiryParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatExpiry( wfTimestampNow() ), + $msg->inLanguage( $lang )->expiryParams( wfTimestampNow() )->plain(), + 'expiryParams is handled correctly' + ); + } + + /** + * @covers Message::timeperiodParams + */ + public function testMessageTimeperiodParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatTimePeriod( 1234 ), + $msg->inLanguage( $lang )->timeperiodParams( 1234 )->plain(), + 'timeperiodParams is handled correctly' + ); + } + + /** + * @covers Message::sizeParams + */ + public function testMessageSizeParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatSize( 123456 ), + $msg->inLanguage( $lang )->sizeParams( 123456 )->plain(), + 'sizeParams is handled correctly' + ); + } + + /** + * @covers Message::bitrateParams + */ + public function testMessageBitrateParams() { + $lang = Language::factory( 'en' ); + $msg = new RawMessage( '$1' ); + + $this->assertEquals( + $lang->formatBitrate( 123456 ), + $msg->inLanguage( $lang )->bitrateParams( 123456 )->plain(), + 'bitrateParams is handled correctly' + ); + } + + /** + * @covers Message::inContentLanguage + */ + public function testInContentLanguage() { + $this->setMwGlobals( 'wgLang', Language::factory( 'fr' ) ); + + // NOTE: make sure internal caching of the message text is reset appropriately + $msg = wfMessage( 'mainpage' ); + $this->assertEquals( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); + $this->assertEquals( 'Main Page', $msg->inContentLanguage()->plain(), "inContentLanguage()" ); + $this->assertEquals( 'Accueil', $msg->inLanguage( 'fr' )->plain(), "inLanguage( 'fr' )" ); + } + + /** + * @covers Message::inContentLanguage + */ + public function testInContentLanguageOverride() { + $this->setMwGlobals( array( + 'wgLang' => Language::factory( 'fr' ), + 'wgForceUIMsgAsContentMsg' => array( 'mainpage' ), + ) ); + + // NOTE: make sure internal caching of the message text is reset appropriately. + // NOTE: wgForceUIMsgAsContentMsg forces the messages *current* language to be used. + $msg = wfMessage( 'mainpage' ); + $this->assertEquals( + 'Accueil', + $msg->inContentLanguage()->plain(), + 'inContentLanguage() with ForceUIMsg override enabled' + ); + $this->assertEquals( 'Main Page', $msg->inLanguage( 'en' )->plain(), "inLanguage( 'en' )" ); + $this->assertEquals( + 'Main Page', + $msg->inContentLanguage()->plain(), + 'inContentLanguage() with ForceUIMsg override enabled' + ); + $this->assertEquals( 'Hauptseite', $msg->inLanguage( 'de' )->plain(), "inLanguage( 'de' )" ); + } + + /** + * @expectedException MWException + * @covers Message::inLanguage + */ + public function testInLanguageThrows() { + wfMessage( 'foo' )->inLanguage( 123 ); + } + + public function keyProvider() { + return array( + 'string' => array( + 'key' => 'mainpage', + 'expected' => array( 'mainpage' ), + ), + 'single' => array( + 'key' => array( 'mainpage' ), + 'expected' => array( 'mainpage' ), + ), + 'multi' => array( + 'key' => array( 'mainpage-foo', 'mainpage-bar', 'mainpage' ), + 'expected' => array( 'mainpage-foo', 'mainpage-bar', 'mainpage' ), + ), + 'empty' => array( + 'key' => array(), + 'expected' => null, + 'exception' => 'InvalidArgumentException', + ), + 'null' => array( + 'key' => null, + 'expected' => null, + 'exception' => 'InvalidArgumentException', + ), + 'bad type' => array( + 'key' => 17, + 'expected' => null, + 'exception' => 'InvalidArgumentException', + ), + ); + } + + /** + * @dataProvider keyProvider() + * + * @covers Message::getKey + */ + public function testGetKey( $key, $expected, $exception = null ) { + if ( $exception ) { + $this->setExpectedException( $exception ); + } + + $msg = new Message( $key ); + $this->assertEquals( $expected, $msg->getKeysToTry() ); + $this->assertEquals( count( $expected ) > 1, $msg->isMultiKey() ); + $this->assertContains( $msg->getKey(), $expected ); + } +} diff --git a/tests/phpunit/includes/MimeMagicTest.php b/tests/phpunit/includes/MimeMagicTest.php new file mode 100644 index 00000000..742d3827 --- /dev/null +++ b/tests/phpunit/includes/MimeMagicTest.php @@ -0,0 +1,49 @@ +<?php +class MimeMagicTest extends MediaWikiTestCase { + + /** @var MimeMagic */ + private $mimeMagic; + + function setUp() { + $this->mimeMagic = MimeMagic::singleton(); + parent::setUp(); + } + + /** + * @dataProvider providerImproveTypeFromExtension + * @param string $ext File extension (no leading dot) + * @param string $oldMime Initially detected MIME + * @param string $expectedMime MIME type after taking extension into account + */ + function testImproveTypeFromExtension( $ext, $oldMime, $expectedMime ) { + $actualMime = $this->mimeMagic->improveTypeFromExtension( $oldMime, $ext ); + $this->assertEquals( $expectedMime, $actualMime ); + } + + function providerImproveTypeFromExtension() { + return array( + array( 'gif', 'image/gif', 'image/gif' ), + array( 'gif', 'unknown/unknown', 'unknown/unknown' ), + array( 'wrl', 'unknown/unknown', 'model/vrml' ), + array( 'txt', 'text/plain', 'text/plain' ), + array( 'csv', 'text/plain', 'text/csv' ), + array( 'tsv', 'text/plain', 'text/tab-separated-values' ), + array( 'json', 'text/plain', 'application/json' ), + array( 'foo', 'application/x-opc+zip', 'application/zip' ), + array( 'docx', 'application/x-opc+zip', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ), + array( 'djvu', 'image/x-djvu', 'image/vnd.djvu' ), + array( 'wav', 'audio/wav', 'audio/wav' ), + ); + } + + /** + * Test to make sure that encoder=ffmpeg2theora doesn't trigger + * MEDIATYPE_VIDEO (bug 63584) + */ + function testOggRecognize() { + $oggFile = __DIR__ . '/../data/media/say-test.ogg'; + $actualType = $this->mimeMagic->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( $actualType, MEDIATYPE_AUDIO ); + } +} diff --git a/tests/phpunit/includes/OutputPageTest.php b/tests/phpunit/includes/OutputPageTest.php new file mode 100644 index 00000000..d7e8cd31 --- /dev/null +++ b/tests/phpunit/includes/OutputPageTest.php @@ -0,0 +1,273 @@ +<?php + +/** + * + * @author Matthew Flaschen + * + * @group Output + * + * @todo factor tests in this class into providers and test methods + * + */ +class OutputPageTest extends MediaWikiTestCase { + const SCREEN_MEDIA_QUERY = 'screen and (min-width: 982px)'; + const SCREEN_ONLY_MEDIA_QUERY = 'only screen and (min-width: 982px)'; + + /** + * Tests a particular case of transformCssMedia, using the given input, globals, + * expected return, and message + * + * Asserts that $expectedReturn is returned. + * + * options['printableQuery'] - value of query string for printable, or omitted for none + * options['handheldQuery'] - value of query string for handheld, or omitted for none + * options['media'] - passed into the method under the same name + * options['expectedReturn'] - expected return value + * options['message'] - PHPUnit message for assertion + * + * @param array $args Key-value array of arguments as shown above + */ + protected function assertTransformCssMediaCase( $args ) { + $queryData = array(); + if ( isset( $args['printableQuery'] ) ) { + $queryData['printable'] = $args['printableQuery']; + } + + if ( isset( $args['handheldQuery'] ) ) { + $queryData['handheld'] = $args['handheldQuery']; + } + + $fauxRequest = new FauxRequest( $queryData, false ); + $this->setMwGlobals( array( + 'wgRequest' => $fauxRequest, + ) ); + + $actualReturn = OutputPage::transformCssMedia( $args['media'] ); + $this->assertSame( $args['expectedReturn'], $actualReturn, $args['message'] ); + } + + /** + * Tests print requests + * @covers OutputPage::transformCssMedia + */ + public function testPrintRequests() { + $this->assertTransformCssMediaCase( array( + 'printableQuery' => '1', + 'media' => 'screen', + 'expectedReturn' => null, + 'message' => 'On printable request, screen returns null' + ) ); + + $this->assertTransformCssMediaCase( array( + 'printableQuery' => '1', + 'media' => self::SCREEN_MEDIA_QUERY, + 'expectedReturn' => null, + 'message' => 'On printable request, screen media query returns null' + ) ); + + $this->assertTransformCssMediaCase( array( + 'printableQuery' => '1', + 'media' => self::SCREEN_ONLY_MEDIA_QUERY, + 'expectedReturn' => null, + 'message' => 'On printable request, screen media query with only returns null' + ) ); + + $this->assertTransformCssMediaCase( array( + 'printableQuery' => '1', + 'media' => 'print', + 'expectedReturn' => '', + 'message' => 'On printable request, media print returns empty string' + ) ); + } + + /** + * Tests screen requests, without either query parameter set + * @covers OutputPage::transformCssMedia + */ + public function testScreenRequests() { + $this->assertTransformCssMediaCase( array( + 'media' => 'screen', + 'expectedReturn' => 'screen', + 'message' => 'On screen request, screen media type is preserved' + ) ); + + $this->assertTransformCssMediaCase( array( + 'media' => 'handheld', + 'expectedReturn' => 'handheld', + 'message' => 'On screen request, handheld media type is preserved' + ) ); + + $this->assertTransformCssMediaCase( array( + 'media' => self::SCREEN_MEDIA_QUERY, + 'expectedReturn' => self::SCREEN_MEDIA_QUERY, + 'message' => 'On screen request, screen media query is preserved.' + ) ); + + $this->assertTransformCssMediaCase( array( + 'media' => self::SCREEN_ONLY_MEDIA_QUERY, + 'expectedReturn' => self::SCREEN_ONLY_MEDIA_QUERY, + 'message' => 'On screen request, screen media query with only is preserved.' + ) ); + + $this->assertTransformCssMediaCase( array( + 'media' => 'print', + 'expectedReturn' => 'print', + 'message' => 'On screen request, print media type is preserved' + ) ); + } + + /** + * Tests handheld behavior + * @covers OutputPage::transformCssMedia + */ + public function testHandheld() { + $this->assertTransformCssMediaCase( array( + 'handheldQuery' => '1', + 'media' => 'handheld', + 'expectedReturn' => '', + 'message' => 'On request with handheld querystring and media is handheld, returns empty string' + ) ); + + $this->assertTransformCssMediaCase( array( + 'handheldQuery' => '1', + 'media' => 'screen', + 'expectedReturn' => null, + 'message' => 'On request with handheld querystring and media is screen, returns null' + ) ); + } + + public static function provideMakeResourceLoaderLink() { + return array( + // Load module script only + array( + array( 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ), + '<script>if(window.mw){ +document.write("\u003Cscript src=\"http://127.0.0.1:8080/w/load.php?debug=false\u0026amp;lang=en\u0026amp;modules=test.foo\u0026amp;only=scripts\u0026amp;skin=fallback\u0026amp;*\"\u003E\u003C/script\u003E"); +}</script> +' + ), + array( + // Don't condition wrap raw modules (like the startup module) + array( 'test.raw', ResourceLoaderModule::TYPE_SCRIPTS ), + '<script src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.raw&only=scripts&skin=fallback&*"></script> +' + ), + // Load module styles only + // This also tests the order the modules are put into the url + array( + array( array( 'test.baz', 'test.foo', 'test.bar' ), ResourceLoaderModule::TYPE_STYLES ), + '<link rel=stylesheet href="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.bar%2Cbaz%2Cfoo&only=styles&skin=fallback&*"> +' + ), + // Load private module (only=scripts) + array( + array( 'test.quux', ResourceLoaderModule::TYPE_SCRIPTS ), + '<script>if(window.mw){ +mw.test.baz({token:123});mw.loader.state({"test.quux":"ready"}); + +}</script> +' + ), + // Load private module (combined) + array( + array( 'test.quux', ResourceLoaderModule::TYPE_COMBINED ), + '<script>if(window.mw){ +mw.loader.implement("test.quux",function($,jQuery){mw.test.baz({token:123});},{"css":[".mw-icon{transition:none}\n"]},{}); + +}</script> +' + ), + // Load module script with with ESI + array( + array( 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS, true ), + '<script><esi:include src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.foo&only=scripts&skin=fallback&*" /></script> +' + ), + // Load module styles with with ESI + array( + array( 'test.foo', ResourceLoaderModule::TYPE_STYLES, true ), + '<style><esi:include src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.foo&only=styles&skin=fallback&*" /></style> +', + ), + // Load no modules + array( + array( array(), ResourceLoaderModule::TYPE_COMBINED ), + '', + ), + // noscript group + array( + array( 'test.noscript', ResourceLoaderModule::TYPE_STYLES ), + '<noscript><link rel=stylesheet href="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.noscript&only=styles&skin=fallback&*"></noscript> +' + ), + // Load two modules in separate groups + array( + array( array( 'test.group.foo', 'test.group.bar' ), ResourceLoaderModule::TYPE_COMBINED ), + '<script src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.group.bar&skin=fallback&*"></script> +<script src="http://127.0.0.1:8080/w/load.php?debug=false&lang=en&modules=test.group.foo&skin=fallback&*"></script> +', + ), + ); + } + + /** + * @dataProvider provideMakeResourceLoaderLink + * @covers OutputPage::makeResourceLoaderLink + */ + public function testMakeResourceLoaderLink( $args, $expectedHtml ) { + $this->setMwGlobals( array( + 'wgResourceLoaderDebug' => false, + 'wgResourceLoaderUseESI' => true, + 'wgLoadScript' => 'http://127.0.0.1:8080/w/load.php', + // Affects whether CDATA is inserted + 'wgWellFormedXml' => false, + ) ); + $class = new ReflectionClass( 'OutputPage' ); + $method = $class->getMethod( 'makeResourceLoaderLink' ); + $method->setAccessible( true ); + $ctx = new RequestContext(); + $ctx->setSkin( SkinFactory::getDefaultInstance()->makeSkin( 'fallback' ) ); + $ctx->setLanguage( 'en' ); + $out = new OutputPage( $ctx ); + $rl = $out->getResourceLoader(); + $rl->register( array( + 'test.foo' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.foo( { a: true } );', + 'styles' => '.mw-test-foo { content: "style"; }', + )), + 'test.bar' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.bar( { a: true } );', + 'styles' => '.mw-test-bar { content: "style"; }', + )), + 'test.baz' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { a: true } );', + 'styles' => '.mw-test-baz { content: "style"; }', + )), + 'test.quux' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { token: 123 } );', + 'styles' => '/* pref-animate=off */ .mw-icon { transition: none; }', + 'group' => 'private', + )), + 'test.raw' => new ResourceLoaderTestModule( array( + 'script' => 'mw.test.baz( { token: 123 } );', + 'isRaw' => true, + )), + 'test.noscript' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-test-noscript { content: "style"; }', + 'group' => 'noscript', + )), + 'test.group.bar' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-group-bar { content: "style"; }', + 'group' => 'bar', + )), + 'test.group.foo' => new ResourceLoaderTestModule( array( + 'styles' => '.mw-group-foo { content: "style"; }', + 'group' => 'foo', + )), + ) ); + $links = $method->invokeArgs( $out, $args ); + // Strip comments to avoid variation due to wgDBname in WikiID and cache key + $actualHtml = preg_replace( '#/\*[^*]+\*/#', '', $links['html'] ); + $this->assertEquals( $expectedHtml, $actualHtml ); + } +} diff --git a/tests/phpunit/includes/PasswordTest.php b/tests/phpunit/includes/PasswordTest.php new file mode 100644 index 00000000..ceb794b5 --- /dev/null +++ b/tests/phpunit/includes/PasswordTest.php @@ -0,0 +1,33 @@ +<?php +/** + * Testing framework for the Password infrastructure + * + * 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 + */ + +class PasswordTest extends MediaWikiTestCase { + /** + * @covers InvalidPassword::equals + */ + public function testInvalidUnequalInvalid() { + $invalid1 = User::getPasswordFactory()->newFromCiphertext( null ); + $invalid2 = User::getPasswordFactory()->newFromCiphertext( null ); + + $this->assertFalse( $invalid1->equals( $invalid2 ) ); + } +} diff --git a/tests/phpunit/includes/PathRouterTest.php b/tests/phpunit/includes/PathRouterTest.php new file mode 100644 index 00000000..0d782687 --- /dev/null +++ b/tests/phpunit/includes/PathRouterTest.php @@ -0,0 +1,264 @@ +<?php + +/** + * Tests for the PathRouter parsing. + * + * @covers PathRouter + */ +class PathRouterTest extends MediaWikiTestCase { + + /** + * @var PathRouter + */ + protected $basicRouter; + + protected function setUp() { + parent::setUp(); + $router = new PathRouter; + $router->add( "/wiki/$1" ); + $this->basicRouter = $router; + } + + /** + * Test basic path parsing + */ + public function testBasic() { + $matches = $this->basicRouter->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test loose path auto-$1 + */ + public function testLoose() { + $router = new PathRouter; + $router->add( "/" ); # Should be the same as "/$1" + $matches = $router->parse( "/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/wiki" ); # Should be the same as /wiki/$1 + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/wiki/" ); # Should be the same as /wiki/$1 + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test to ensure that path is based on specifity, not order + */ + public function testOrder() { + $router = new PathRouter; + $router->add( "/$1" ); + $router->add( "/a/$1" ); + $router->add( "/b/$1" ); + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + + $router = new PathRouter; + $router->add( "/b/$1" ); + $router->add( "/a/$1" ); + $router->add( "/$1" ); + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo" ) ); + } + + /** + * Test the handling of key based arrays with a url parameter + */ + public function testKeyParameter() { + $router = new PathRouter; + $router->add( array( 'edit' => "/edit/$1" ), array( 'action' => '$key' ) ); + $matches = $router->parse( "/edit/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'action' => 'edit' ) ); + } + + /** + * Test the handling of $2 inside paths + */ + public function testAdditionalParameter() { + // Basic $2 + $router = new PathRouter; + $router->add( '/$2/$1', array( 'test' => '$2' ) ); + $matches = $router->parse( "/asdf/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test' => 'asdf' ) ); + } + + /** + * Test additional restricted value parameter + */ + public function testRestrictedValue() { + $router = new PathRouter; + $router->add( '/$2/$1', + array( 'test' => '$2' ), + array( '$2' => array( 'a', 'b' ) ) + ); + $router->add( '/$2/$1', + array( 'test2' => '$2' ), + array( '$2' => 'c' ) + ); + $router->add( '/$1' ); + + $matches = $router->parse( "/asdf/Foo" ); + $this->assertEquals( $matches, array( 'title' => "asdf/Foo" ) ); + + $matches = $router->parse( "/a/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test' => 'a' ) ); + + $matches = $router->parse( "/c/Foo" ); + $this->assertEquals( $matches, array( 'title' => "Foo", 'test2' => 'c' ) ); + } + + public function callbackForTest( &$matches, $data ) { + $matches['x'] = $data['$1']; + $matches['foo'] = $data['foo']; + } + + public function testCallback() { + $router = new PathRouter; + $router->add( "/$1", + array( 'a' => 'b', 'data:foo' => 'bar' ), + array( 'callback' => array( $this, 'callbackForTest' ) ) + ); + $matches = $router->parse( '/Foo' ); + $this->assertEquals( $matches, array( + 'title' => "Foo", + 'x' => 'Foo', + 'a' => 'b', + 'foo' => 'bar' + ) ); + } + + /** + * Test to ensure that matches are not made if a parameter expects nonexistent input + */ + public function testFail() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => "$1$2" ) ); + $matches = $router->parse( "/wiki/A" ); + $this->assertEquals( array(), $matches ); + } + + /** + * Test to ensure weight of paths is handled correctly + */ + public function testWeight() { + $router = new PathRouter; + $router->addStrict( "/Bar", array( 'ping' => 'pong' ) ); + $router->add( "/asdf-$1", array( 'title' => 'qwerty-$1' ) ); + $router->add( "/$1" ); + $router->add( "/qwerty-$1", array( 'title' => 'asdf-$1' ) ); + $router->addStrict( "/Baz", array( 'marco' => 'polo' ) ); + $router->add( "/a/$1" ); + $router->add( "/asdf/$1" ); + $router->add( "/$2/$1", array( 'unrestricted' => '$2' ) ); + $router->add( array( 'qwerty' => "/qwerty/$1" ), array( 'qwerty' => '$key' ) ); + $router->add( "/$2/$1", array( 'restricted-to-y' => '$2' ), array( '$2' => 'y' ) ); + + foreach ( + array( + '/Foo' => array( 'title' => 'Foo' ), + '/Bar' => array( 'ping' => 'pong' ), + '/Baz' => array( 'marco' => 'polo' ), + '/asdf-foo' => array( 'title' => 'qwerty-foo' ), + '/qwerty-bar' => array( 'title' => 'asdf-bar' ), + '/a/Foo' => array( 'title' => 'Foo' ), + '/asdf/Foo' => array( 'title' => 'Foo' ), + '/qwerty/Foo' => array( 'title' => 'Foo', 'qwerty' => 'qwerty' ), + '/baz/Foo' => array( 'title' => 'Foo', 'unrestricted' => 'baz' ), + '/y/Foo' => array( 'title' => 'Foo', 'restricted-to-y' => 'y' ), + ) as $path => $result + ) { + $this->assertEquals( $router->parse( $path ), $result ); + } + } + + /** + * Make sure the router handles titles like Special:Recentchanges correctly + */ + public function testSpecial() { + $matches = $this->basicRouter->parse( "/wiki/Special:Recentchanges" ); + $this->assertEquals( $matches, array( 'title' => "Special:Recentchanges" ) ); + } + + /** + * Make sure the router decodes urlencoding properly + */ + public function testUrlencoding() { + $matches = $this->basicRouter->parse( "/wiki/Title_With%20Space" ); + $this->assertEquals( $matches, array( 'title' => "Title_With Space" ) ); + } + + public static function provideRegexpChars() { + return array( + array( "$" ), + array( "$1" ), + array( "\\" ), + array( "\\$1" ), + ); + } + + /** + * Make sure the router doesn't break on special characters like $ used in regexp replacements + * @dataProvider provideRegexpChars + */ + public function testRegexpChars( $char ) { + $matches = $this->basicRouter->parse( "/wiki/$char" ); + $this->assertEquals( $matches, array( 'title' => "$char" ) ); + } + + /** + * Make sure the router handles characters like +&() properly + */ + public function testCharacters() { + $matches = $this->basicRouter->parse( "/wiki/Plus+And&Dollar\\Stuff();[]{}*" ); + $this->assertEquals( $matches, array( 'title' => "Plus+And&Dollar\\Stuff();[]{}*" ) ); + } + + /** + * Make sure the router handles unicode characters correctly + * @depends testSpecial + * @depends testUrlencoding + * @depends testCharacters + */ + public function testUnicode() { + $matches = $this->basicRouter->parse( "/wiki/Spécial:Modifications_récentes" ); + $this->assertEquals( $matches, array( 'title' => "Spécial:Modifications_récentes" ) ); + + $matches = $this->basicRouter->parse( "/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes" ); + $this->assertEquals( $matches, array( 'title' => "Spécial:Modifications_récentes" ) ); + } + + /** + * Ensure the router doesn't choke on long paths. + */ + public function testLength() { + // @codingStandardsIgnoreStart Ignore long line warnings + $matches = $this->basicRouter->parse( "/wiki/Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum." ); + $this->assertEquals( $matches, array( 'title' => "Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum." ) ); + // @codingStandardsIgnoreEnd + } + + /** + * Ensure that the php passed site of parameter values are not urldecoded + */ + public function testPatternUrlencoding() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => '%20:$1' ) ); + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => '%20:Foo' ) ); + } + + /** + * Ensure that raw parameter values do not have any variable replacements or urldecoding + */ + public function testRawParamValue() { + $router = new PathRouter; + $router->add( "/wiki/$1", array( 'title' => array( 'value' => 'bar%20$1' ) ) ); + $matches = $router->parse( "/wiki/Foo" ); + $this->assertEquals( $matches, array( 'title' => 'bar%20$1' ) ); + } +} diff --git a/tests/phpunit/includes/PreferencesTest.php b/tests/phpunit/includes/PreferencesTest.php new file mode 100644 index 00000000..5841bb6f --- /dev/null +++ b/tests/phpunit/includes/PreferencesTest.php @@ -0,0 +1,91 @@ +<?php + +/** + * @group Database + */ +class PreferencesTest extends MediaWikiTestCase { + /** + * @var User[] + */ + private $prefUsers; + /** + * @var RequestContext + */ + private $context; + + public function __construct() { + parent::__construct(); + + $this->prefUsers['noemail'] = new User; + + $this->prefUsers['notauth'] = new User; + $this->prefUsers['notauth'] + ->setEmail( 'noauth@example.org' ); + + $this->prefUsers['auth'] = new User; + $this->prefUsers['auth'] + ->setEmail( 'noauth@example.org' ); + $this->prefUsers['auth'] + ->setEmailAuthenticationTimestamp( 1330946623 ); + + $this->context = new RequestContext; + $this->context->setTitle( Title::newFromText( 'PreferencesTest' ) ); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableEmail' => true, + 'wgEmailAuthentication' => true, + ) ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserHasNoEmail() { + $prefs = $this->prefsFor( 'noemail' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-none', $prefs['emailaddress']['cssclass'] ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserEmailNotAuthenticated() { + $prefs = $this->prefsFor( 'notauth' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-not-authenticated', $prefs['emailaddress']['cssclass'] ); + } + + /** + * Placeholder to verify bug 34302 + * @covers Preferences::profilePreferences + */ + public function testEmailFieldsWhenUserEmailIsAuthenticated() { + $prefs = $this->prefsFor( 'auth' ); + $this->assertArrayHasKey( 'cssclass', + $prefs['emailaddress'] + ); + $this->assertEquals( 'mw-email-authenticated', $prefs['emailaddress']['cssclass'] ); + } + + /** Helper */ + protected function prefsFor( $user_key ) { + $preferences = array(); + Preferences::profilePreferences( + $this->prefUsers[$user_key], + $this->context, + $preferences + ); + + return $preferences; + } +} diff --git a/tests/phpunit/includes/RequestContextTest.php b/tests/phpunit/includes/RequestContextTest.php new file mode 100644 index 00000000..cae0e52e --- /dev/null +++ b/tests/phpunit/includes/RequestContextTest.php @@ -0,0 +1,96 @@ +<?php + +/** + * @group Database + * @group RequestContext + */ +class RequestContextTest extends MediaWikiTestCase { + + /** + * Test the relationship between title and wikipage in RequestContext + * @covers RequestContext::getWikiPage + * @covers RequestContext::getTitle + */ + public function testWikiPageTitle() { + $context = new RequestContext(); + + $curTitle = Title::newFromText( "A" ); + $context->setTitle( $curTitle ); + $this->assertTrue( $curTitle->equals( $context->getWikiPage()->getTitle() ), + "When a title is first set WikiPage should be created on-demand for that title." ); + + $curTitle = Title::newFromText( "B" ); + $context->setWikiPage( WikiPage::factory( $curTitle ) ); + $this->assertTrue( $curTitle->equals( $context->getTitle() ), + "Title must be updated when a new WikiPage is provided." ); + + $curTitle = Title::newFromText( "C" ); + $context->setTitle( $curTitle ); + $this->assertTrue( + $curTitle->equals( $context->getWikiPage()->getTitle() ), + "When a title is updated the WikiPage should be purged " + . "and recreated on-demand with the new title." + ); + } + + /** + * @covers RequestContext::importScopedSession + */ + public function testImportScopedSession() { + $context = RequestContext::getMain(); + + $oInfo = $context->exportSession(); + $this->assertEquals( '127.0.0.1', $oInfo['ip'], "Correct initial IP address." ); + $this->assertEquals( 0, $oInfo['userId'], "Correct initial user ID." ); + + $user = User::newFromName( 'UnitTestContextUser' ); + $user->addToDatabase(); + + $sinfo = array( + 'sessionId' => 'd612ee607c87e749ef14da4983a702cd', + 'userId' => $user->getId(), + 'ip' => '192.0.2.0', + 'headers' => array( + 'USER-AGENT' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:18.0) Gecko/20100101 Firefox/18.0' + ) + ); + // importScopedSession() sets these variables + $this->setMwGlobals( array( + 'wgUser' => new User, + 'wgRequest' => new FauxRequest, + ) ); + $sc = RequestContext::importScopedSession( $sinfo ); // load new context + + $info = $context->exportSession(); + $this->assertEquals( $sinfo['ip'], $info['ip'], "Correct IP address." ); + $this->assertEquals( $sinfo['headers'], $info['headers'], "Correct headers." ); + $this->assertEquals( $sinfo['sessionId'], $info['sessionId'], "Correct session ID." ); + $this->assertEquals( $sinfo['userId'], $info['userId'], "Correct user ID." ); + $this->assertEquals( + $sinfo['ip'], + $context->getRequest()->getIP(), + "Correct context IP address." + ); + $this->assertEquals( + $sinfo['headers'], + $context->getRequest()->getAllHeaders(), + "Correct context headers." + ); + $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." ); + $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." ); + $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." ); + $this->assertEquals( + 'UnitTestContextUser', + $context->getUser()->getName(), + "Correct context user name." + ); + + unset( $sc ); // restore previous context + + $info = $context->exportSession(); + $this->assertEquals( $oInfo['ip'], $info['ip'], "Correct initial IP address." ); + $this->assertEquals( $oInfo['headers'], $info['headers'], "Correct initial headers." ); + $this->assertEquals( $oInfo['sessionId'], $info['sessionId'], "Correct initial session ID." ); + $this->assertEquals( $oInfo['userId'], $info['userId'], "Correct initial user ID." ); + } +} diff --git a/tests/phpunit/includes/RevisionStorageTest.php b/tests/phpunit/includes/RevisionStorageTest.php new file mode 100644 index 00000000..9a429bcb --- /dev/null +++ b/tests/phpunit/includes/RevisionStorageTest.php @@ -0,0 +1,574 @@ +<?php + +/** + * Test class for Revision storage. + * + * @group ContentHandler + * @group Database + * ^--- important, causes temporary tables to be used instead of the real database + * + * @group medium + * ^--- important, causes tests not to fail with timeout + */ +class RevisionStorageTest extends MediaWikiTestCase { + /** + * @var WikiPage $the_page + */ + private $the_page; + + function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed = array_merge( $this->tablesUsed, + array( 'page', + 'revision', + 'text', + + 'recentchanges', + 'logging', + + 'page_props', + 'pagelinks', + 'categorylinks', + 'langlinks', + 'externallinks', + 'imagelinks', + 'templatelinks', + 'iwlinks' ) ); + } + + protected function setUp() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::setUp(); + + $wgExtraNamespaces[12312] = 'Dummy'; + $wgExtraNamespaces[12313] = 'Dummy_talk'; + + $wgNamespaceContentModels[12312] = 'DUMMY'; + $wgContentHandlers['DUMMY'] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + if ( !$this->the_page ) { + $this->the_page = $this->createPage( + 'RevisionStorageTest_the_page', + "just a dummy page", + CONTENT_MODEL_WIKITEXT + ); + } + } + + protected function tearDown() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::tearDown(); + + unset( $wgExtraNamespaces[12312] ); + unset( $wgExtraNamespaces[12313] ); + + unset( $wgNamespaceContentModels[12312] ); + unset( $wgContentHandlers['DUMMY'] ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function makeRevision( $props = null ) { + if ( $props === null ) { + $props = array(); + } + + if ( !isset( $props['content'] ) && !isset( $props['text'] ) ) { + $props['text'] = 'Lorem Ipsum'; + } + + if ( !isset( $props['comment'] ) ) { + $props['comment'] = 'just a test'; + } + + if ( !isset( $props['page'] ) ) { + $props['page'] = $this->the_page->getId(); + } + + $rev = new Revision( $props ); + + $dbw = wfgetDB( DB_MASTER ); + $rev->insertOn( $dbw ); + + return $rev; + } + + protected function createPage( $page, $text, $model = null ) { + if ( is_string( $page ) ) { + if ( !preg_match( '/:/', $page ) && + ( $model === null || $model === CONTENT_MODEL_WIKITEXT ) + ) { + $ns = $this->getDefaultWikitextNS(); + $page = MWNamespace::getCanonicalName( $ns ) . ':' . $page; + } + + $page = Title::newFromText( $page ); + } + + if ( $page instanceof Title ) { + $page = new WikiPage( $page ); + } + + if ( $page->exists() ) { + $page->doDeleteArticle( "done" ); + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); + + return $page; + } + + protected function assertRevEquals( Revision $orig, Revision $rev = null ) { + $this->assertNotNull( $rev, 'missing revision' ); + + $this->assertEquals( $orig->getId(), $rev->getId() ); + $this->assertEquals( $orig->getPage(), $rev->getPage() ); + $this->assertEquals( $orig->getTimestamp(), $rev->getTimestamp() ); + $this->assertEquals( $orig->getUser(), $rev->getUser() ); + $this->assertEquals( $orig->getContentModel(), $rev->getContentModel() ); + $this->assertEquals( $orig->getContentFormat(), $rev->getContentFormat() ); + $this->assertEquals( $orig->getSha1(), $rev->getSha1() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructFromRow() { + $orig = $this->makeRevision(); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'revision', '*', array( 'rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = new Revision( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromRow + */ + public function testNewFromRow() { + $orig = $this->makeRevision(); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'revision', '*', array( 'rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = Revision::newFromRow( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromArchiveRow + */ + public function testNewFromArchiveRow() { + $page = $this->createPage( + 'RevisionStorageTest_testNewFromArchiveRow', + 'Lorem Ipsum', + CONTENT_MODEL_WIKITEXT + ); + $orig = $page->getRevision(); + $page->doDeleteArticle( 'test Revision::newFromArchiveRow' ); + + $dbr = wfgetDB( DB_SLAVE ); + $res = $dbr->select( 'archive', '*', array( 'ar_rev_id' => $orig->getId() ) ); + $this->assertTrue( is_object( $res ), 'query failed' ); + + $row = $res->fetchObject(); + $res->free(); + + $rev = Revision::newFromArchiveRow( $row ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::newFromId + */ + public function testNewFromId() { + $orig = $this->makeRevision(); + + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertRevEquals( $orig, $rev ); + } + + /** + * @covers Revision::fetchRevision + */ + public function testFetchRevision() { + $page = $this->createPage( + 'RevisionStorageTest_testFetchRevision', + 'one', + CONTENT_MODEL_WIKITEXT + ); + + // Hidden process cache assertion below + $page->getRevision()->getId(); + + $page->doEditContent( new WikitextContent( 'two' ), 'second rev' ); + $id = $page->getRevision()->getId(); + + $res = Revision::fetchRevision( $page->getTitle() ); + + #note: order is unspecified + $rows = array(); + while ( ( $row = $res->fetchObject() ) ) { + $rows[$row->rev_id] = $row; + } + + $this->assertEquals( 1, count( $rows ), 'expected exactly one revision' ); + $this->assertArrayHasKey( $id, $rows, 'missing revision with id ' . $id ); + } + + /** + * @covers Revision::selectFields + */ + public function testSelectFields() { + global $wgContentHandlerUseDB; + + $fields = Revision::selectFields(); + + $this->assertTrue( in_array( 'rev_id', $fields ), 'missing rev_id in list of fields' ); + $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields' ); + $this->assertTrue( + in_array( 'rev_timestamp', $fields ), + 'missing rev_timestamp in list of fields' + ); + $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields' ); + + if ( $wgContentHandlerUseDB ) { + $this->assertTrue( in_array( 'rev_content_model', $fields ), + 'missing rev_content_model in list of fields' ); + $this->assertTrue( in_array( 'rev_content_format', $fields ), + 'missing rev_content_format in list of fields' ); + } + } + + /** + * @covers Revision::getPage + */ + public function testGetPage() { + $page = $this->the_page; + + $orig = $this->makeRevision( array( 'page' => $page->getId() ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( $page->getId(), $rev->getPage() ); + } + + /** + * @covers Revision::getText + */ + public function testGetText() { + $this->hideDeprecated( 'Revision::getText' ); + + $orig = $this->makeRevision( array( 'text' => 'hello hello.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello.', $rev->getText() ); + } + + /** + * @covers Revision::getContent + */ + public function testGetContent_failure() { + $rev = new Revision( array( + 'page' => $this->the_page->getId(), + 'content_model' => $this->the_page->getContentModel(), + 'text_id' => 123456789, // not in the test DB + ) ); + + $this->assertNull( $rev->getContent(), + "getContent() should return null if the revision's text blob could not be loaded." ); + + //NOTE: check this twice, once for lazy initialization, and once with the cached value. + $this->assertNull( $rev->getContent(), + "getContent() should return null if the revision's text blob could not be loaded." ); + } + + /** + * @covers Revision::getContent + */ + public function testGetContent() { + $orig = $this->makeRevision( array( 'text' => 'hello hello.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello.', $rev->getContent()->getNativeData() ); + } + + /** + * @covers Revision::getRawText + */ + public function testGetRawText() { + $this->hideDeprecated( 'Revision::getRawText' ); + + $orig = $this->makeRevision( array( 'text' => 'hello hello raw.' ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( 'hello hello raw.', $rev->getRawText() ); + } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $orig = $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $orig = $this->makeRevision( array( + 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT, + 'content_format' => CONTENT_FORMAT_JAVASCRIPT + ) ); + $rev = Revision::newFromId( $orig->getId() ); + + $this->assertEquals( CONTENT_FORMAT_JAVASCRIPT, $rev->getContentFormat() ); + } + + /** + * @covers Revision::isCurrent + */ + public function testIsCurrent() { + $page = $this->createPage( + 'RevisionStorageTest_testIsCurrent', + 'Lorem Ipsum', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + # @todo find out if this should be true + # $this->assertTrue( $rev1->isCurrent() ); + + $rev1x = Revision::newFromId( $rev1->getId() ); + $this->assertTrue( $rev1x->isCurrent() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev' + ); + $rev2 = $page->getRevision(); + + # @todo find out if this should be true + # $this->assertTrue( $rev2->isCurrent() ); + + $rev1x = Revision::newFromId( $rev1->getId() ); + $this->assertFalse( $rev1x->isCurrent() ); + + $rev2x = Revision::newFromId( $rev2->getId() ); + $this->assertTrue( $rev2x->isCurrent() ); + } + + /** + * @covers Revision::getPrevious + */ + public function testGetPrevious() { + $page = $this->createPage( + 'RevisionStorageTest_testGetPrevious', + 'Lorem Ipsum testGetPrevious', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + $this->assertNull( $rev1->getPrevious() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev testGetPrevious' ); + $rev2 = $page->getRevision(); + + $this->assertNotNull( $rev2->getPrevious() ); + $this->assertEquals( $rev1->getId(), $rev2->getPrevious()->getId() ); + } + + /** + * @covers Revision::getNext + */ + public function testGetNext() { + $page = $this->createPage( + 'RevisionStorageTest_testGetNext', + 'Lorem Ipsum testGetNext', + CONTENT_MODEL_WIKITEXT + ); + $rev1 = $page->getRevision(); + + $this->assertNull( $rev1->getNext() ); + + $page->doEditContent( + ContentHandler::makeContent( 'Bla bla', $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + 'second rev testGetNext' + ); + $rev2 = $page->getRevision(); + + $this->assertNotNull( $rev1->getNext() ); + $this->assertEquals( $rev2->getId(), $rev1->getNext()->getId() ); + } + + /** + * @covers Revision::newNullRevision + */ + public function testNewNullRevision() { + $page = $this->createPage( + 'RevisionStorageTest_testNewNullRevision', + 'some testing text', + CONTENT_MODEL_WIKITEXT + ); + $orig = $page->getRevision(); + + $dbw = wfGetDB( DB_MASTER ); + $rev = Revision::newNullRevision( $dbw, $page->getId(), 'a null revision', false ); + + $this->assertNotEquals( $orig->getId(), $rev->getId(), + 'new null revision shold have a different id from the original revision' ); + $this->assertEquals( $orig->getTextId(), $rev->getTextId(), + 'new null revision shold have the same text id as the original revision' ); + $this->assertEquals( 'some testing text', $rev->getContent()->getNativeData() ); + } + + public static function provideUserWasLastToEdit() { + return array( + array( #0 + 3, true, # actually the last edit + ), + array( #1 + 2, true, # not the current edit, but still by this user + ), + array( #2 + 1, false, # edit by another user + ), + array( #3 + 0, false, # first edit, by this user, but another user edited in the mean time + ), + ); + } + + /** + * @dataProvider provideUserWasLastToEdit + */ + public function testUserWasLastToEdit( $sinceIdx, $expectedLast ) { + $userA = User::newFromName( "RevisionStorageTest_userA" ); + $userB = User::newFromName( "RevisionStorageTest_userB" ); + + if ( $userA->getId() === 0 ) { + $userA = User::createNew( $userA->getName() ); + } + + if ( $userB->getId() === 0 ) { + $userB = User::createNew( $userB->getName() ); + } + + $ns = $this->getDefaultWikitextNS(); + + $dbw = wfGetDB( DB_MASTER ); + $revisions = array(); + + // create revisions ----------------------------- + $page = WikiPage::factory( Title::newFromText( + 'RevisionStorageTest_testUserWasLastToEdit', $ns ) ); + $page->insertOn( $dbw ); + + # zero + $revisions[0] = new Revision( array( + 'page' => $page->getId(), + // we need the title to determine the page's default content model + 'title' => $page->getTitle(), + 'timestamp' => '20120101000000', + 'user' => $userA->getId(), + 'text' => 'zero', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit zero' + ) ); + $revisions[0]->insertOn( $dbw ); + + # one + $revisions[1] = new Revision( array( + 'page' => $page->getId(), + // still need the title, because $page->getId() is 0 (there's no entry in the page table) + 'title' => $page->getTitle(), + 'timestamp' => '20120101000100', + 'user' => $userA->getId(), + 'text' => 'one', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit one' + ) ); + $revisions[1]->insertOn( $dbw ); + + # two + $revisions[2] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000200', + 'user' => $userB->getId(), + 'text' => 'two', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit two' + ) ); + $revisions[2]->insertOn( $dbw ); + + # three + $revisions[3] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000300', + 'user' => $userA->getId(), + 'text' => 'three', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit three' + ) ); + $revisions[3]->insertOn( $dbw ); + + # four + $revisions[4] = new Revision( array( + 'page' => $page->getId(), + 'title' => $page->getTitle(), + 'timestamp' => '20120101000200', + 'user' => $userA->getId(), + 'text' => 'zero', + 'content_model' => CONTENT_MODEL_WIKITEXT, + 'summary' => 'edit four' + ) ); + $revisions[4]->insertOn( $dbw ); + + // test it --------------------------------- + $since = $revisions[$sinceIdx]->getTimestamp(); + + $wasLast = Revision::userWasLastToEdit( $dbw, $page->getId(), $userA->getId(), $since ); + + $this->assertEquals( $expectedLast, $wasLast ); + } +} diff --git a/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php b/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php new file mode 100644 index 00000000..d5e47c82 --- /dev/null +++ b/tests/phpunit/includes/RevisionStorageTestContentHandlerUseDB.php @@ -0,0 +1,89 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- important, causes temporary tables to be used instead of the real database + */ +class RevisionTestContentHandlerUseDB extends RevisionStorageTest { + + protected function setUp() { + $this->setMwGlobals( 'wgContentHandlerUseDB', false ); + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + + parent::setUp(); + } + + /** + * @covers Revision::selectFields + */ + public function testSelectFields() { + $fields = Revision::selectFields(); + + $this->assertTrue( in_array( 'rev_id', $fields ), 'missing rev_id in list of fields' ); + $this->assertTrue( in_array( 'rev_page', $fields ), 'missing rev_page in list of fields' ); + $this->assertTrue( + in_array( 'rev_timestamp', $fields ), + 'missing rev_timestamp in list of fields' + ); + $this->assertTrue( in_array( 'rev_user', $fields ), 'missing rev_user in list of fields' ); + + $this->assertFalse( + in_array( 'rev_content_model', $fields ), + 'missing rev_content_model in list of fields' + ); + $this->assertFalse( + in_array( 'rev_content_format', $fields ), + 'missing rev_content_format in list of fields' + ); + } + + /** + * @covers Revision::getContentModel + */ + public function testGetContentModel() { + try { + $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT ) ); + + $this->fail( "Creating JavaScript content on a wikitext page should fail with " + . "\$wgContentHandlerUseDB disabled" ); + } catch ( MWException $ex ) { + $this->assertTrue( true ); // ok + } + } + + /** + * @covers Revision::getContentFormat + */ + public function testGetContentFormat() { + try { + // @todo change this to test failure on using a non-standard (but supported) format + // for a content model supported in the given location. As of 1.21, there are + // no alternative formats for any of the standard content models that could be + // used for this though. + + $this->makeRevision( array( 'text' => 'hello hello.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT, + 'content_format' => 'text/javascript' ) ); + + $this->fail( "Creating JavaScript content on a wikitext page should fail with " + . "\$wgContentHandlerUseDB disabled" ); + } catch ( MWException $ex ) { + $this->assertTrue( true ); // ok + } + } +} diff --git a/tests/phpunit/includes/RevisionTest.php b/tests/phpunit/includes/RevisionTest.php new file mode 100644 index 00000000..4623b383 --- /dev/null +++ b/tests/phpunit/includes/RevisionTest.php @@ -0,0 +1,506 @@ +<?php + +/** + * @group ContentHandler + */ +class RevisionTest extends MediaWikiTestCase { + protected function setUp() { + global $wgContLang; + + parent::setUp(); + + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + 'wgLanguageCode' => 'en', + 'wgLegacyEncoding' => false, + 'wgCompressRevisions' => false, + + 'wgContentHandlerTextFallback' => 'ignore', + ) ); + + $this->mergeMwGlobalArrayValue( + 'wgExtraNamespaces', + array( + 12312 => 'Dummy', + 12313 => 'Dummy_talk', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgNamespaceContentModels', + array( + 12312 => 'testing', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgContentHandlers', + array( + 'testing' => 'DummyContentHandlerForTesting', + 'RevisionTestModifyableContent' => 'RevisionTestModifyableContentHandler', + ) + ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + function tearDown() { + global $wgContLang; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + + parent::tearDown(); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionText() { + $row = new stdClass; + $row->old_flags = ''; + $row->old_text = 'This is a bunch of revision text.'; + $this->assertEquals( + 'This is a bunch of revision text.', + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip'; + $row->old_text = gzdeflate( 'This is a bunch of revision text.' ); + $this->assertEquals( + 'This is a bunch of revision text.', + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8Native() { + $row = new stdClass; + $row->old_flags = 'utf-8'; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8Legacy() { + $row = new stdClass; + $row->old_flags = ''; + $row->old_text = "Wiki est l'\xe9cole superieur !"; + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8NativeGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip,utf-8'; + $row->old_text = gzdeflate( "Wiki est l'\xc3\xa9cole superieur !" ); + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::getRevisionText + */ + public function testGetRevisionTextUtf8LegacyGzip() { + $this->checkPHPExtension( 'zlib' ); + + $row = new stdClass; + $row->old_flags = 'gzip'; + $row->old_text = gzdeflate( "Wiki est l'\xe9cole superieur !" ); + $GLOBALS['wgLegacyEncoding'] = 'iso-8859-1'; + $this->assertEquals( + "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ) ); + } + + /** + * @covers Revision::compressRevisionText + */ + public function testCompressRevisionTextUtf8() { + $row = new stdClass; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $row->old_flags = Revision::compressRevisionText( $row->old_text ); + $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ), + "Flags should contain 'utf-8'" ); + $this->assertFalse( false !== strpos( $row->old_flags, 'gzip' ), + "Flags should not contain 'gzip'" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + $row->old_text, "Direct check" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ), "getRevisionText" ); + } + + /** + * @covers Revision::compressRevisionText + */ + public function testCompressRevisionTextUtf8Gzip() { + $this->checkPHPExtension( 'zlib' ); + $this->setMwGlobals( 'wgCompressRevisions', true ); + + $row = new stdClass; + $row->old_text = "Wiki est l'\xc3\xa9cole superieur !"; + $row->old_flags = Revision::compressRevisionText( $row->old_text ); + $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ), + "Flags should contain 'utf-8'" ); + $this->assertTrue( false !== strpos( $row->old_flags, 'gzip' ), + "Flags should contain 'gzip'" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + gzinflate( $row->old_text ), "Direct check" ); + $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !", + Revision::getRevisionText( $row ), "getRevisionText" ); + } + + # ========================================================================= + + /** + * @param string $text + * @param string $title + * @param string $model + * @param string $format + * + * @return Revision + */ + function newTestRevision( $text, $title = "Test", + $model = CONTENT_MODEL_WIKITEXT, $format = null + ) { + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + + $content = ContentHandler::makeContent( $text, $title, $model, $format ); + + $rev = new Revision( + array( + 'id' => 42, + 'page' => 23, + 'title' => $title, + + 'content' => $content, + 'length' => $content->getSize(), + 'comment' => "testing", + 'minor_edit' => false, + + 'content_format' => $format, + ) + ); + + return $rev; + } + + function dataGetContentModel() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, CONTENT_MODEL_WIKITEXT ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_MODEL_CSS ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, "testing" ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentModel + * @covers Revision::getContentModel + */ + public function testGetContentModel( $text, $title, $model, $format, $expectedModel ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedModel, $rev->getContentModel() ); + } + + function dataGetContentFormat() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, CONTENT_FORMAT_WIKITEXT ), + array( 'hello world', 'Help:Hello', CONTENT_MODEL_CSS, null, CONTENT_FORMAT_CSS ), + array( 'hello world', 'User:hello/there.css', null, null, CONTENT_FORMAT_CSS ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, "testing" ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentFormat + * @covers Revision::getContentFormat + */ + public function testGetContentFormat( $text, $title, $model, $format, $expectedFormat ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedFormat, $rev->getContentFormat() ); + } + + function dataGetContentHandler() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, 'WikitextContentHandler' ), + array( 'hello world', 'User:hello/there.css', null, null, 'CssContentHandler' ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, 'DummyContentHandlerForTesting' ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContentHandler + * @covers Revision::getContentHandler + */ + public function testGetContentHandler( $text, $title, $model, $format, $expectedClass ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedClass, get_class( $rev->getContentHandler() ) ); + } + + function dataGetContent() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( + serialize( 'hello world' ), + 'Hello', + "testing", + null, + Revision::FOR_PUBLIC, + serialize( 'hello world' ) + ), + array( + serialize( 'hello world' ), + 'Dummy:Hello', + null, + null, + Revision::FOR_PUBLIC, + serialize( 'hello world' ) + ), + ); + } + + /** + * @group Database + * @dataProvider dataGetContent + * @covers Revision::getContent + */ + public function testGetContent( $text, $title, $model, $format, + $audience, $expectedSerialization + ) { + $rev = $this->newTestRevision( $text, $title, $model, $format ); + $content = $rev->getContent( $audience ); + + $this->assertEquals( + $expectedSerialization, + is_null( $content ) ? null : $content->serialize( $format ) + ); + } + + function dataGetText() { + //NOTE: we expect the help namespace to always contain wikitext + return array( + array( 'hello world', 'Help:Hello', null, null, Revision::FOR_PUBLIC, 'hello world' ), + array( serialize( 'hello world' ), 'Hello', "testing", null, Revision::FOR_PUBLIC, null ), + array( serialize( 'hello world' ), 'Dummy:Hello', null, null, Revision::FOR_PUBLIC, null ), + ); + } + + /** + * @group Database + * @dataProvider dataGetText + * @covers Revision::getText + */ + public function testGetText( $text, $title, $model, $format, $audience, $expectedText ) { + $this->hideDeprecated( 'Revision::getText' ); + + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getText( $audience ) ); + } + + /** + * @group Database + * @dataProvider dataGetText + * @covers Revision::getRawText + */ + public function testGetRawText( $text, $title, $model, $format, $audience, $expectedText ) { + $this->hideDeprecated( 'Revision::getRawText' ); + + $rev = $this->newTestRevision( $text, $title, $model, $format ); + + $this->assertEquals( $expectedText, $rev->getRawText( $audience ) ); + } + + public function dataGetSize() { + return array( + array( "hello world.", CONTENT_MODEL_WIKITEXT, 12 ), + array( serialize( "hello world." ), "testing", 12 ), + ); + } + + /** + * @covers Revision::getSize + * @group Database + * @dataProvider dataGetSize + */ + public function testGetSize( $text, $model, $expected_size ) { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSize', $model ); + $this->assertEquals( $expected_size, $rev->getSize() ); + } + + public function dataGetSha1() { + return array( + array( "hello world.", CONTENT_MODEL_WIKITEXT, Revision::base36Sha1( "hello world." ) ), + array( + serialize( "hello world." ), + "testing", + Revision::base36Sha1( serialize( "hello world." ) ) + ), + ); + } + + /** + * @covers Revision::getSha1 + * @group Database + * @dataProvider dataGetSha1 + */ + public function testGetSha1( $text, $model, $expected_hash ) { + $rev = $this->newTestRevision( $text, 'RevisionTest_testGetSha1', $model ); + $this->assertEquals( $expected_hash, $rev->getSha1() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructWithText() { + $this->hideDeprecated( "Revision::getText" ); + + $rev = new Revision( array( + 'text' => 'hello world.', + 'content_model' => CONTENT_MODEL_JAVASCRIPT + ) ); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * @covers Revision::__construct + */ + public function testConstructWithContent() { + $this->hideDeprecated( "Revision::getText" ); + + $title = Title::newFromText( 'RevisionTest_testConstructWithContent' ); + + $rev = new Revision( array( + 'content' => ContentHandler::makeContent( 'hello world.', $title, CONTENT_MODEL_JAVASCRIPT ), + ) ); + + $this->assertNotNull( $rev->getText(), 'no content text' ); + $this->assertNotNull( $rev->getContent(), 'no content object available' ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContent()->getModel() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $rev->getContentModel() ); + } + + /** + * Tests whether $rev->getContent() returns a clone when needed. + * + * @group Database + * @covers Revision::getContent + */ + public function testGetContentClone() { + $content = new RevisionTestModifyableContent( "foo" ); + + $rev = new Revision( + array( + 'id' => 42, + 'page' => 23, + 'title' => Title::newFromText( "testGetContentClone_dummy" ), + + 'content' => $content, + 'length' => $content->getSize(), + 'comment' => "testing", + 'minor_edit' => false, + ) + ); + + $content = $rev->getContent( Revision::RAW ); + $content->setText( "bar" ); + + $content2 = $rev->getContent( Revision::RAW ); + // content is mutable, expect clone + $this->assertNotSame( $content, $content2, "expected a clone" ); + // clone should contain the original text + $this->assertEquals( "foo", $content2->getText() ); + + $content2->setText( "bla bla" ); + $this->assertEquals( "bar", $content->getText() ); // clones should be independent + } + + /** + * Tests whether $rev->getContent() returns the same object repeatedly if appropriate. + * + * @group Database + * @covers Revision::getContent + */ + public function testGetContentUncloned() { + $rev = $this->newTestRevision( "hello", "testGetContentUncloned_dummy", CONTENT_MODEL_WIKITEXT ); + $content = $rev->getContent( Revision::RAW ); + $content2 = $rev->getContent( Revision::RAW ); + + // for immutable content like wikitext, this should be the same object + $this->assertSame( $content, $content2 ); + } +} + +class RevisionTestModifyableContent extends TextContent { + public function __construct( $text ) { + parent::__construct( $text, "RevisionTestModifyableContent" ); + } + + public function copy() { + return new RevisionTestModifyableContent( $this->mText ); + } + + public function getText() { + return $this->mText; + } + + public function setText( $text ) { + $this->mText = $text; + } +} + +class RevisionTestModifyableContentHandler extends TextContentHandler { + + public function __construct() { + parent::__construct( "RevisionTestModifyableContent", array( CONTENT_FORMAT_TEXT ) ); + } + + public function unserializeContent( $text, $format = null ) { + $this->checkFormat( $format ); + + return new RevisionTestModifyableContent( $text ); + } + + public function makeEmptyContent() { + return new RevisionTestModifyableContent( '' ); + } +} diff --git a/tests/phpunit/includes/SampleTest.php b/tests/phpunit/includes/SampleTest.php new file mode 100644 index 00000000..25858110 --- /dev/null +++ b/tests/phpunit/includes/SampleTest.php @@ -0,0 +1,108 @@ +<?php + +class TestSample extends MediaWikiLangTestCase { + + /** + * Anything that needs to happen before your tests should go here. + */ + protected function setUp() { + // Be sure to do call the parent setup and teardown functions. + // This makes sure that all the various cleanup and restorations + // happen as they should (including the restoration for setMwGlobals). + parent::setUp(); + + // This sets the globals and will restore them automatically + // after each test. + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + 'wgLanguageCode' => 'en', + 'wgCapitalLinks' => true, + ) ); + } + + /** + * Anything cleanup you need to do should go here. + */ + protected function tearDown() { + parent::tearDown(); + } + + /** + * Name tests so that PHPUnit can turn them into sentences when + * they run. While MediaWiki isn't strictly an Agile Programming + * project, you are encouraged to use the naming described under + * "Agile Documentation" at + * http://www.phpunit.de/manual/3.4/en/other-uses-for-tests.html + */ + public function testTitleObjectStringConversion() { + $title = Title::newFromText( "text" ); + $this->assertInstanceOf( 'Title', $title, "Title creation" ); + $this->assertEquals( "Text", $title, "Automatic string conversion" ); + + $title = Title::newFromText( "text", NS_MEDIA ); + $this->assertEquals( "Media:Text", $title, "Title creation with namespace" ); + } + + /** + * If you want to run a the same test with a variety of data, use a data provider. + * see: http://www.phpunit.de/manual/3.4/en/writing-tests-for-phpunit.html + */ + public static function provideTitles() { + return array( + array( 'Text', NS_MEDIA, 'Media:Text' ), + array( 'Text', null, 'Text' ), + array( 'text', null, 'Text' ), + array( 'Text', NS_USER, 'User:Text' ), + array( 'Photo.jpg', NS_FILE, 'File:Photo.jpg' ) + ); + } + + /** + * @dataProvider provideTitles + * @codingStandardsIgnoreStart Ignore long line warning + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.dataProvider + * @codingStandardsIgnoreEnd + */ + public function testCreateBasicListOfTitles( $titleName, $ns, $text ) { + $title = Title::newFromText( $titleName, $ns ); + $this->assertEquals( $text, "$title", "see if '$titleName' matches '$text'" ); + } + + public function testSetUpMainPageTitleForNextTest() { + $title = Title::newMainPage(); + $this->assertEquals( "Main Page", "$title", "Test initial creation of a title" ); + + return $title; + } + + /** + * Instead of putting a bunch of tests in a single test method, + * you should put only one or two tests in each test method. This + * way, the test method names can remain descriptive. + * + * If you want to make tests depend on data created in another + * method, you can create dependencies feed whatever you return + * from the dependant method (e.g. testInitialCreation in this + * example) as arguments to the next method (e.g. $title in + * testTitleDepends is whatever testInitialCreatiion returned.) + */ + + /** + * @depends testSetUpMainPageTitleForNextTest + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.depends + */ + public function testCheckMainPageTitleIsConsideredLocal( $title ) { + $this->assertTrue( $title->isLocal() ); + } + + // @codingStandardsIgnoreStart Ignore long line warning + /** + * @expectedException MWException object + * See http://phpunit.de/manual/3.7/en/appendixes.annotations.html#appendixes.annotations.expectedException + */ + // @codingStandardsIgnoreEnd + public function testTitleObjectFromObject() { + $title = Title::newFromText( Title::newFromText( "test" ) ); + $this->assertEquals( "Test", $title->isLocal() ); + } +} diff --git a/tests/phpunit/includes/SanitizerTest.php b/tests/phpunit/includes/SanitizerTest.php new file mode 100644 index 00000000..50c1e509 --- /dev/null +++ b/tests/phpunit/includes/SanitizerTest.php @@ -0,0 +1,349 @@ +<?php + +/** + * @todo Tests covering decodeCharReferences can be refactored into a single + * method and dataprovider. + */ +class SanitizerTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + AutoLoader::loadClass( 'Sanitizer' ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeNamedEntities() { + $this->assertEquals( + "\xc3\xa9cole", + Sanitizer::decodeCharReferences( 'école' ), + 'decode named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeNumericEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode numeric entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole!", + Sanitizer::decodeCharReferences( "Ĉio bonas dans l'école!" ), + 'decode mixed numeric/named entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testDecodeMixedComplexEntities() { + $this->assertEquals( + "\xc4\x88io bonas dans l'\xc3\xa9cole! (mais pas Ĉio dans l'école)", + Sanitizer::decodeCharReferences( + "Ĉio bonas dans l'école! (mais pas &#x108;io dans l'&eacute;cole)" + ), + 'decode mixed complex entities' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidAmpersand() { + $this->assertEquals( + 'a & b', + Sanitizer::decodeCharReferences( 'a & b' ), + 'Invalid ampersand' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidEntities() { + $this->assertEquals( + '&foo;', + Sanitizer::decodeCharReferences( '&foo;' ), + 'Invalid named entity' + ); + } + + /** + * @covers Sanitizer::decodeCharReferences + */ + public function testInvalidNumberedEntities() { + $this->assertEquals( + UTF8_REPLACEMENT, + Sanitizer::decodeCharReferences( "�" ), + 'Invalid numbered entity' + ); + } + + /** + * @covers Sanitizer::removeHTMLtags + * @dataProvider provideHtml5Tags + * + * @param string $tag Name of an HTML5 element (ie: 'video') + * @param bool $escaped Whether sanitizer let the tag in or escape it (ie: '<video>') + */ + public function testRemovehtmltagsOnHtml5Tags( $tag, $escaped ) { + $this->setMwGlobals( array( + 'wgUseTidy' => false + ) ); + + if ( $escaped ) { + $this->assertEquals( "<$tag>", + Sanitizer::removeHTMLtags( "<$tag>" ) + ); + } else { + $this->assertEquals( "<$tag></$tag>\n", + Sanitizer::removeHTMLtags( "<$tag>" ) + ); + } + } + + /** + * Provide HTML5 tags + */ + public static function provideHtml5Tags() { + $ESCAPED = true; # We want tag to be escaped + $VERBATIM = false; # We want to keep the tag + return array( + array( 'data', $VERBATIM ), + array( 'mark', $VERBATIM ), + array( 'time', $VERBATIM ), + array( 'video', $ESCAPED ), + ); + } + + function dataRemoveHTMLtags() { + return array( + // former testSelfClosingTag + array( + '<div>Hello world</div />', + '<div>Hello world</div>', + 'Self-closing closing div' + ), + // Make sure special nested HTML5 semantics are not broken + // http://www.whatwg.org/html/text-level-semantics.html#the-kbd-element + array( + '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>', + '<kbd><kbd>Shift</kbd>+<kbd>F3</kbd></kbd>', + 'Nested <kbd>.' + ), + // http://www.whatwg.org/html/text-level-semantics.html#the-sub-and-sup-elements + array( + '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>', + '<var>x<sub><var>i</var></sub></var>, <var>y<sub><var>i</var></sub></var>', + 'Nested <var>.' + ), + // http://www.whatwg.org/html/text-level-semantics.html#the-dfn-element + array( + '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>', + '<dfn><abbr title="Garage Door Opener">GDO</abbr></dfn>', + '<abbr> inside <dfn>', + ), + ); + } + + /** + * @dataProvider dataRemoveHTMLtags + * @covers Sanitizer::removeHTMLtags + */ + public function testRemoveHTMLtags( $input, $output, $msg = null ) { + $GLOBALS['wgUseTidy'] = false; + $this->assertEquals( $output, Sanitizer::removeHTMLtags( $input ), $msg ); + } + + /** + * @dataProvider provideTagAttributesToDecode + * @covers Sanitizer::decodeTagAttributes + */ + public function testDecodeTagAttributes( $expected, $attributes, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::decodeTagAttributes( $attributes ), + $message + ); + } + + public static function provideTagAttributesToDecode() { + return array( + array( array( 'foo' => 'bar' ), 'foo=bar', 'Unquoted attribute' ), + array( array( 'foo' => 'bar' ), ' foo = bar ', 'Spaced attribute' ), + array( array( 'foo' => 'bar' ), 'foo="bar"', 'Double-quoted attribute' ), + array( array( 'foo' => 'bar' ), 'foo=\'bar\'', 'Single-quoted attribute' ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( + array( 'foo' => 'bar', 'baz' => 'foo' ), + 'foo=\'bar\' baz="foo"', + 'Several attributes' + ), + array( array( ':foo' => 'bar' ), ':foo=\'bar\'', 'Leading :' ), + array( array( '_foo' => 'bar' ), '_foo=\'bar\'', 'Leading _' ), + array( array( 'foo' => 'bar' ), 'Foo=\'bar\'', 'Leading capital' ), + array( array( 'foo' => 'BAR' ), 'FOO=BAR', 'Attribute keys are normalized to lowercase' ), + + # Invalid beginning + array( array(), '-foo=bar', 'Leading - is forbidden' ), + array( array(), '.foo=bar', 'Leading . is forbidden' ), + array( array( 'foo-bar' => 'bar' ), 'foo-bar=bar', 'A - is allowed inside the attribute' ), + array( array( 'foo-' => 'bar' ), 'foo-=bar', 'A - is allowed inside the attribute' ), + array( array( 'foo.bar' => 'baz' ), 'foo.bar=baz', 'A . is allowed inside the attribute' ), + array( array( 'foo.' => 'baz' ), 'foo.=baz', 'A . is allowed as last character' ), + array( array( 'foo6' => 'baz' ), 'foo6=baz', 'Numbers are allowed' ), + + # This bit is more relaxed than XML rules, but some extensions use + # it, like ProofreadPage (see bug 27539) + array( array( '1foo' => 'baz' ), '1foo=baz', 'Leading numbers are allowed' ), + array( array(), 'foo$=baz', 'Symbols are not allowed' ), + array( array(), 'foo@=baz', 'Symbols are not allowed' ), + array( array(), 'foo~=baz', 'Symbols are not allowed' ), + array( + array( 'foo' => '1[#^`*%w/(' ), + 'foo=1[#^`*%w/(', + 'All kind of characters are allowed as values' + ), + array( + array( 'foo' => '1[#^`*%\'w/(' ), + 'foo="1[#^`*%\'w/("', + 'Double quotes are allowed if quoted by single quotes' + ), + array( + array( 'foo' => '1[#^`*%"w/(' ), + 'foo=\'1[#^`*%"w/(\'', + 'Single quotes are allowed if quoted by double quotes' + ), + array( array( 'foo' => '&"' ), 'foo=&"', 'Special chars can be provided as entities' ), + array( array( 'foo' => '&foobar;' ), 'foo=&foobar;', 'Entity-like items are accepted' ), + ); + } + + /** + * @dataProvider provideDeprecatedAttributes + * @covers Sanitizer::fixTagAttributes + */ + public function testDeprecatedAttributesUnaltered( $inputAttr, $inputEl, $message = '' ) { + $this->assertEquals( " $inputAttr", + Sanitizer::fixTagAttributes( $inputAttr, $inputEl ), + $message + ); + } + + public static function provideDeprecatedAttributes() { + /** array( <attribute>, <element>, [message] ) */ + return array( + array( 'clear="left"', 'br' ), + array( 'clear="all"', 'br' ), + array( 'width="100"', 'td' ), + array( 'nowrap="true"', 'td' ), + array( 'nowrap=""', 'td' ), + array( 'align="right"', 'td' ), + array( 'align="center"', 'table' ), + array( 'align="left"', 'tr' ), + array( 'align="center"', 'div' ), + array( 'align="left"', 'h1' ), + array( 'align="left"', 'p' ), + ); + } + + /** + * @dataProvider provideCssCommentsFixtures + * @covers Sanitizer::checkCss + */ + public function testCssCommentsChecking( $expected, $css, $message = '' ) { + $this->assertEquals( $expected, + Sanitizer::checkCss( $css ), + $message + ); + } + + public static function provideCssCommentsFixtures() { + /** array( <expected>, <css>, [message] ) */ + return array( + // Valid comments spanning entire input + array( '/**/', '/**/' ), + array( '/* comment */', '/* comment */' ), + // Weird stuff + array( ' ', '/****/' ), + array( ' ', '/* /* */' ), + array( 'display: block;', "display:/* foo */block;" ), + array( 'display: block;', "display:\\2f\\2a foo \\2a\\2f block;", + 'Backslash-escaped comments must be stripped (bug 28450)' ), + array( '', '/* unfinished comment structure', + 'Remove anything after a comment-start token' ), + array( '', "\\2f\\2a unifinished comment'", + 'Remove anything after a backslash-escaped comment-start token' ), + array( + '/* insecure input */', + 'filter: progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\');' + ), + array( + '/* insecure input */', + '-ms-filter: "progid:DXImageTransform.Microsoft.AlphaImageLoader' + . '(src=\'asdf.png\',sizingMethod=\'scale\')";' + ), + array( '/* insecure input */', 'width: expression(1+1);' ), + array( '/* insecure input */', 'background-image: image(asdf.png);' ), + array( '/* insecure input */', 'background-image: -webkit-image(asdf.png);' ), + array( '/* insecure input */', 'background-image: -moz-image(asdf.png);' ), + array( '/* insecure input */', 'background-image: image-set("asdf.png" 1x, "asdf.png" 2x);' ), + array( + '/* insecure input */', + 'background-image: -webkit-image-set("asdf.png" 1x, "asdf.png" 2x);' + ), + array( + '/* insecure input */', + 'background-image: -moz-image-set("asdf.png" 1x, "asdf.png" 2x);' + ), + ); + } + + /** + * Test for support or lack of support for specific attributes in the attribute whitelist. + */ + public static function provideAttributeSupport() { + /** array( <attributes>, <expected>, <message> ) */ + return array( + array( + 'div', + ' role="presentation"', + ' role="presentation"', + 'Support for WAI-ARIA\'s role="presentation".' + ), + array( 'div', ' role="main"', '', "Other WAI-ARIA roles are currently not supported." ), + ); + } + + /** + * @dataProvider provideAttributeSupport + * @covers Sanitizer::fixTagAttributes + */ + public function testAttributeSupport( $tag, $attributes, $expected, $message ) { + $this->assertEquals( $expected, + Sanitizer::fixTagAttributes( $attributes, $tag ), + $message + ); + } +} diff --git a/tests/phpunit/includes/SanitizerValidateEmailTest.php b/tests/phpunit/includes/SanitizerValidateEmailTest.php new file mode 100644 index 00000000..14911f04 --- /dev/null +++ b/tests/phpunit/includes/SanitizerValidateEmailTest.php @@ -0,0 +1,103 @@ +<?php + +/** + * @covers Sanitizer::validateEmail + * @todo all test methods in this class should be refactored and... + * use a single test method and a single data provider... + */ +class SanitizerValidateEmailTest extends MediaWikiTestCase { + + private function checkEmail( $addr, $expected = true, $msg = '' ) { + if ( $msg == '' ) { + $msg = "Testing $addr"; + } + + $this->assertEquals( + $expected, + Sanitizer::validateEmail( $addr ), + $msg + ); + } + + private function valid( $addr, $msg = '' ) { + $this->checkEmail( $addr, true, $msg ); + } + + private function invalid( $addr, $msg = '' ) { + $this->checkEmail( $addr, false, $msg ); + } + + public function testEmailWellKnownUserAtHostDotTldAreValid() { + $this->valid( 'user@example.com' ); + $this->valid( 'user@example.museum' ); + } + + public function testEmailWithUpperCaseCharactersAreValid() { + $this->valid( 'USER@example.com' ); + $this->valid( 'user@EXAMPLE.COM' ); + $this->valid( 'user@Example.com' ); + $this->valid( 'USER@eXAMPLE.com' ); + } + + public function testEmailWithAPlusInUserName() { + $this->valid( 'user+sub@example.com' ); + $this->valid( 'user+@example.com' ); + } + + public function testEmailDoesNotNeedATopLevelDomain() { + $this->valid( "user@localhost" ); + $this->valid( "FooBar@localdomain" ); + $this->valid( "nobody@mycompany" ); + } + + public function testEmailWithWhiteSpacesBeforeOrAfterAreInvalids() { + $this->invalid( " user@host.com" ); + $this->invalid( "user@host.com " ); + $this->invalid( "\tuser@host.com" ); + $this->invalid( "user@host.com\t" ); + } + + public function testEmailWithWhiteSpacesAreInvalids() { + $this->invalid( "User user@host" ); + $this->invalid( "first last@mycompany" ); + $this->invalid( "firstlast@my company" ); + } + + /** + * bug 26948 : comma were matched by an incorrect regexp range + */ + public function testEmailWithCommasAreInvalids() { + $this->invalid( "user,foo@example.org" ); + $this->invalid( "userfoo@ex,ample.org" ); + } + + public function testEmailWithHyphens() { + $this->valid( "user-foo@example.org" ); + $this->valid( "userfoo@ex-ample.org" ); + } + + public function testEmailDomainCanNotBeginWithDot() { + $this->invalid( "user@." ); + $this->invalid( "user@.localdomain" ); + $this->invalid( "user@localdomain." ); + $this->valid( "user.@localdomain" ); + $this->valid( ".@localdomain" ); + $this->invalid( ".@a............" ); + } + + public function testEmailWithFunnyCharacters() { + $this->valid( "\$user!ex{this}@123.com" ); + } + + public function testEmailTopLevelDomainCanBeNumerical() { + $this->valid( "user@example.1234" ); + } + + public function testEmailWithoutAtSignIsInvalid() { + $this->invalid( 'useràexample.com' ); + } + + public function testEmailWithOneCharacterDomainIsValid() { + $this->valid( 'user@a' ); + } +} diff --git a/tests/phpunit/includes/SiteConfigurationTest.php b/tests/phpunit/includes/SiteConfigurationTest.php new file mode 100644 index 00000000..6547c873 --- /dev/null +++ b/tests/phpunit/includes/SiteConfigurationTest.php @@ -0,0 +1,363 @@ +<?php + +class SiteConfigurationTest extends MediaWikiTestCase { + + /** + * @var SiteConfiguration + */ + protected $mConf; + + protected function setUp() { + parent::setUp(); + + $this->mConf = new SiteConfiguration; + + $this->mConf->suffixes = array( 'wikipedia' => 'wiki' ); + $this->mConf->wikis = array( 'enwiki', 'dewiki', 'frwiki' ); + $this->mConf->settings = array( + 'simple' => array( + 'wiki' => 'wiki', + 'tag' => 'tag', + 'enwiki' => 'enwiki', + 'dewiki' => 'dewiki', + 'frwiki' => 'frwiki', + ), + + 'fallback' => array( + 'default' => 'default', + 'wiki' => 'wiki', + 'tag' => 'tag', + ), + + 'params' => array( + 'default' => '$lang $site $wiki', + ), + + '+global' => array( + 'wiki' => array( + 'wiki' => 'wiki', + ), + 'tag' => array( + 'tag' => 'tag', + ), + 'enwiki' => array( + 'enwiki' => 'enwiki', + ), + 'dewiki' => array( + 'dewiki' => 'dewiki', + ), + 'frwiki' => array( + 'frwiki' => 'frwiki', + ), + ), + + 'merge' => array( + '+wiki' => array( + 'wiki' => 'wiki', + ), + '+tag' => array( + 'tag' => 'tag', + ), + 'default' => array( + 'default' => 'default', + ), + '+enwiki' => array( + 'enwiki' => 'enwiki', + ), + '+dewiki' => array( + 'dewiki' => 'dewiki', + ), + '+frwiki' => array( + 'frwiki' => 'frwiki', + ), + ), + ); + + $GLOBALS['global'] = array( 'global' => 'global' ); + } + + /** + * This function is used as a callback within the tests below + */ + public static function getSiteParamsCallback( $conf, $wiki ) { + $site = null; + $lang = null; + foreach ( $conf->suffixes as $suffix ) { + if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { + $site = $suffix; + $lang = substr( $wiki, 0, -strlen( $suffix ) ); + break; + } + } + + return array( + 'suffix' => $site, + 'lang' => $lang, + 'params' => array( + 'lang' => $lang, + 'site' => $site, + 'wiki' => $wiki, + ), + 'tags' => array( 'tag' ), + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDb() { + $this->assertEquals( + array( 'wikipedia', 'en' ), + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB()' + ); + $this->assertEquals( + array( 'wikipedia', '' ), + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() on a suffix' + ); + $this->assertEquals( + array( null, null ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki' + ); + + $this->mConf->suffixes = array( 'wiki', '' ); + $this->assertEquals( + array( '', 'wikien' ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki (2)' + ); + } + + /** + * @covers SiteConfiguration::getLocalDatabases + */ + public function testGetLocalDatabases() { + $this->assertEquals( + array( 'enwiki', 'dewiki', 'frwiki' ), + $this->mConf->getLocalDatabases(), + 'getLocalDatabases()' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testGetConfVariables() { + $this->assertEquals( + 'enwiki', + $this->mConf->get( 'simple', 'enwiki', 'wiki' ), + 'get(): simple setting on an existing wiki' + ); + $this->assertEquals( + 'dewiki', + $this->mConf->get( 'simple', 'dewiki', 'wiki' ), + 'get(): simple setting on an existing wiki (2)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'simple', 'frwiki', 'wiki' ), + 'get(): simple setting on an existing wiki (3)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'simple', 'wiki', 'wiki' ), + 'get(): simple setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'simple', 'eswiki', 'wiki' ), + 'get(): simple setting on an non-existing wiki' + ); + + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'enwiki', 'wiki' ), + 'get(): fallback setting on an existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'fallback', 'dewiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an existing wiki (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'wiki', 'wiki' ), + 'get(): fallback setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'wiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an suffix (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'fallback', 'eswiki', 'wiki' ), + 'get(): fallback setting on an non-existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'fallback', 'eswiki', 'wiki', array(), array( 'tag' ) ), + 'get(): fallback setting on an non-existing wiki (with wiki tag)' + ); + + $common = array( 'wiki' => 'wiki', 'default' => 'default' ); + $commonTag = array( 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ); + $this->assertEquals( + array( 'enwiki' => 'enwiki' ) + $common, + $this->mConf->get( 'merge', 'enwiki', 'wiki' ), + 'get(): merging setting on an existing wiki' + ); + $this->assertEquals( + array( 'enwiki' => 'enwiki' ) + $commonTag, + $this->mConf->get( 'merge', 'enwiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (with tag)' + ); + $this->assertEquals( + array( 'dewiki' => 'dewiki' ) + $common, + $this->mConf->get( 'merge', 'dewiki', 'wiki' ), + 'get(): merging setting on an existing wiki (2)' + ); + $this->assertEquals( + array( 'dewiki' => 'dewiki' ) + $commonTag, + $this->mConf->get( 'merge', 'dewiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (2) (with tag)' + ); + $this->assertEquals( + array( 'frwiki' => 'frwiki' ) + $common, + $this->mConf->get( 'merge', 'frwiki', 'wiki' ), + 'get(): merging setting on an existing wiki (3)' + ); + $this->assertEquals( + array( 'frwiki' => 'frwiki' ) + $commonTag, + $this->mConf->get( 'merge', 'frwiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an existing wiki (3) (with tag)' + ); + $this->assertEquals( + array( 'wiki' => 'wiki' ) + $common, + $this->mConf->get( 'merge', 'wiki', 'wiki' ), + 'get(): merging setting on an suffix' + ); + $this->assertEquals( + array( 'wiki' => 'wiki' ) + $commonTag, + $this->mConf->get( 'merge', 'wiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an suffix (with tag)' + ); + $this->assertEquals( + $common, + $this->mConf->get( 'merge', 'eswiki', 'wiki' ), + 'get(): merging setting on an non-existing wiki' + ); + $this->assertEquals( + $commonTag, + $this->mConf->get( 'merge', 'eswiki', 'wiki', array(), array( 'tag' ) ), + 'get(): merging setting on an non-existing wiki (with tag)' + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDbWithCallback() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + array( 'wiki', 'en' ), + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB() with callback' + ); + $this->assertEquals( + array( 'wiki', '' ), + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() with callback on a suffix' + ); + $this->assertEquals( + array( null, null ), + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() with callback on a non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testParameterReplacement() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + 'en wiki enwiki', + $this->mConf->get( 'params', 'enwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki' + ); + $this->assertEquals( + 'de wiki dewiki', + $this->mConf->get( 'params', 'dewiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (2)' + ); + $this->assertEquals( + 'fr wiki frwiki', + $this->mConf->get( 'params', 'frwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (3)' + ); + $this->assertEquals( + ' wiki wiki', + $this->mConf->get( 'params', 'wiki', 'wiki' ), + 'get(): parameter replacement on an suffix' + ); + $this->assertEquals( + 'es wiki eswiki', + $this->mConf->get( 'params', 'eswiki', 'wiki' ), + 'get(): parameter replacement on an non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::getAll + */ + public function testGetAllGlobals() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $getall = array( + 'simple' => 'enwiki', + 'fallback' => 'tag', + 'params' => 'en wiki enwiki', + 'global' => array( 'enwiki' => 'enwiki' ) + $GLOBALS['global'], + 'merge' => array( + 'enwiki' => 'enwiki', + 'tag' => 'tag', + 'wiki' => 'wiki', + 'default' => 'default' + ), + ); + $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); + + $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); + + $this->assertEquals( + $getall['simple'], + $GLOBALS['simple'], + 'extractAllGlobals(): simple setting' + ); + $this->assertEquals( + $getall['fallback'], + $GLOBALS['fallback'], + 'extractAllGlobals(): fallback setting' + ); + $this->assertEquals( + $getall['params'], + $GLOBALS['params'], + 'extractAllGlobals(): parameter replacement' + ); + $this->assertEquals( + $getall['global'], + $GLOBALS['global'], + 'extractAllGlobals(): merging with global' + ); + $this->assertEquals( + $getall['merge'], + $GLOBALS['merge'], + 'extractAllGlobals(): merging setting' + ); + } +} diff --git a/tests/phpunit/includes/SpecialPageTest.php b/tests/phpunit/includes/SpecialPageTest.php new file mode 100644 index 00000000..245cdffd --- /dev/null +++ b/tests/phpunit/includes/SpecialPageTest.php @@ -0,0 +1,105 @@ +<?php + +/** + * @covers SpecialPage + * + * @group Database + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class SpecialPageTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgScript' => '/index.php', + 'wgContLang' => Language::factory( 'en' ) + ) ); + } + + /** + * @dataProvider getTitleForProvider + */ + public function testGetTitleFor( $expectedName, $name ) { + $title = SpecialPage::getTitleFor( $name ); + $expected = Title::makeTitle( NS_SPECIAL, $expectedName ); + $this->assertEquals( $expected, $title ); + } + + public function getTitleForProvider() { + return array( + array( 'UserLogin', 'Userlogin' ) + ); + } + + /** + * @expectedException PHPUnit_Framework_Error_Notice + */ + public function testInvalidGetTitleFor() { + $title = SpecialPage::getTitleFor( 'cat' ); + $expected = Title::makeTitle( NS_SPECIAL, 'Cat' ); + $this->assertEquals( $expected, $title ); + } + + /** + * @expectedException PHPUnit_Framework_Error_Notice + * @dataProvider getTitleForWithWarningProvider + */ + public function testGetTitleForWithWarning( $expected, $name ) { + $title = SpecialPage::getTitleFor( $name ); + $this->assertEquals( $expected, $title ); + } + + public function getTitleForWithWarningProvider() { + return array( + array( Title::makeTitle( NS_SPECIAL, 'UserLogin' ), 'UserLogin' ) + ); + } + + /** + * @dataProvider requireLoginAnonProvider + */ + public function testRequireLoginAnon( $expected, $reason, $title ) { + $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' ); + + $user = User::newFromId( 0 ); + $specialPage->getContext()->setUser( $user ); + $specialPage->getContext()->setLanguage( Language::factory( 'en' ) ); + + $this->setExpectedException( 'UserNotLoggedIn', $expected ); + + // $specialPage->requireLogin( [ $reason [, $title ] ] ) + call_user_func_array( + array( $specialPage, 'requireLogin' ), + array_filter( array( $reason, $title ) ) + ); + } + + public function requireLoginAnonProvider() { + $lang = 'en'; + + $expected1 = wfMessage( 'exception-nologin-text' )->inLanguage( $lang )->text(); + $expected2 = wfMessage( 'about' )->inLanguage( $lang )->text(); + + return array( + array( $expected1, null, null ), + array( $expected2, 'about', null ), + array( $expected2, 'about', 'about' ), + ); + } + + public function testRequireLoginNotAnon() { + $specialPage = new SpecialPage( 'Watchlist', 'viewmywatchlist' ); + + $user = User::newFromName( "UTSysop" ); + $specialPage->getContext()->setUser( $user ); + + $specialPage->requireLogin(); + + // no exception thrown, logged in use can access special page + $this->assertTrue( true ); + } + +} diff --git a/tests/phpunit/includes/StatusTest.php b/tests/phpunit/includes/StatusTest.php new file mode 100644 index 00000000..628c59b6 --- /dev/null +++ b/tests/phpunit/includes/StatusTest.php @@ -0,0 +1,573 @@ +<?php + +/** + * @author Adam Shorland + */ +class StatusTest extends MediaWikiLangTestCase { + + public function testCanConstruct() { + new Status(); + $this->assertTrue( true ); + } + + /** + * @dataProvider provideValues + * @covers Status::newGood + */ + public function testNewGood( $value = null ) { + $status = Status::newGood( $value ); + $this->assertTrue( $status->isGood() ); + $this->assertTrue( $status->isOK() ); + $this->assertEquals( $value, $status->getValue() ); + } + + public static function provideValues() { + return array( + array(), + array( 'foo' ), + array( array( 'foo' => 'bar' ) ), + array( new Exception() ), + array( 1234 ), + ); + } + + /** + * @covers Status::newFatal + */ + public function testNewFatalWithMessage() { + $message = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + + $status = Status::newFatal( $message ); + $this->assertFalse( $status->isGood() ); + $this->assertFalse( $status->isOK() ); + $this->assertEquals( $message, $status->getMessage() ); + } + + /** + * @covers Status::newFatal + */ + public function testNewFatalWithString() { + $message = 'foo'; + $status = Status::newFatal( $message ); + $this->assertFalse( $status->isGood() ); + $this->assertFalse( $status->isOK() ); + $this->assertEquals( $message, $status->getMessage()->getKey() ); + } + + /** + * @dataProvider provideSetResult + * @covers Status::setResult + */ + public function testSetResult( $ok, $value = null ) { + $status = new Status(); + $status->setResult( $ok, $value ); + $this->assertEquals( $ok, $status->isOK() ); + $this->assertEquals( $value, $status->getValue() ); + } + + public static function provideSetResult() { + return array( + array( true ), + array( false ), + array( true, 'value' ), + array( false, 'value' ), + ); + } + + /** + * @dataProvider provideIsOk + * @covers Status::isOk + */ + public function testIsOk( $ok ) { + $status = new Status(); + $status->ok = $ok; + $this->assertEquals( $ok, $status->isOK() ); + } + + public static function provideIsOk() { + return array( + array( true ), + array( false ), + ); + } + + /** + * @covers Status::getValue + */ + public function testGetValue() { + $status = new Status(); + $status->value = 'foobar'; + $this->assertEquals( 'foobar', $status->getValue() ); + } + + /** + * @dataProvider provideIsGood + * @covers Status::isGood + */ + public function testIsGood( $ok, $errors, $expected ) { + $status = new Status(); + $status->ok = $ok; + $status->errors = $errors; + $this->assertEquals( $expected, $status->isGood() ); + } + + public static function provideIsGood() { + return array( + array( true, array(), true ), + array( true, array( 'foo' ), false ), + array( false, array(), false ), + array( false, array( 'foo' ), false ), + ); + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::warning + * @covers Status::getWarningsArray + * @covers Status::getStatusArray + */ + public function testWarningWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->warning( $message ); + } + $warnings = $status->getWarningsArray(); + + $this->assertEquals( count( $messages ), count( $warnings ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $warnings[$key], $expectedArray ); + } + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::error + * @covers Status::getErrorsArray + * @covers Status::getStatusArray + */ + public function testErrorWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->error( $message ); + } + $errors = $status->getErrorsArray(); + + $this->assertEquals( count( $messages ), count( $errors ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $errors[$key], $expectedArray ); + } + } + + /** + * @dataProvider provideMockMessageDetails + * @covers Status::fatal + * @covers Status::getErrorsArray + * @covers Status::getStatusArray + */ + public function testFatalWithMessage( $mockDetails ) { + $status = new Status(); + $messages = $this->getMockMessages( $mockDetails ); + + foreach ( $messages as $message ) { + $status->fatal( $message ); + } + $errors = $status->getErrorsArray(); + + $this->assertEquals( count( $messages ), count( $errors ) ); + foreach ( $messages as $key => $message ) { + $expectedArray = array_merge( array( $message->getKey() ), $message->getParams() ); + $this->assertEquals( $errors[$key], $expectedArray ); + } + $this->assertFalse( $status->isOK() ); + } + + protected function getMockMessage( $key = 'key', $params = array() ) { + $message = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + $message->expects( $this->atLeastOnce() ) + ->method( 'getKey' ) + ->will( $this->returnValue( $key ) ); + $message->expects( $this->atLeastOnce() ) + ->method( 'getParams' ) + ->will( $this->returnValue( $params ) ); + return $message; + } + + /** + * @param array $messageDetails E.g. array( 'KEY' => array(/PARAMS/) ) + * @return Message[] + */ + protected function getMockMessages( $messageDetails ) { + $messages = array(); + foreach ( $messageDetails as $key => $paramsArray ) { + $messages[] = $this->getMockMessage( $key, $paramsArray ); + } + return $messages; + } + + public static function provideMockMessageDetails() { + return array( + array( array( 'key1' => array( 'foo' => 'bar' ) ) ), + array( array( 'key1' => array( 'foo' => 'bar' ), 'key2' => array( 'foo2' => 'bar2' ) ) ), + ); + } + + /** + * @covers Status::merge + */ + public function testMerge() { + $status1 = new Status(); + $status2 = new Status(); + $message1 = $this->getMockMessage( 'warn1' ); + $message2 = $this->getMockMessage( 'error2' ); + $status1->warning( $message1 ); + $status2->error( $message2 ); + + $status1->merge( $status2 ); + $this->assertEquals( + 2, + count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() ) + ); + } + + /** + * @covers Status::merge + */ + public function testMergeWithOverwriteValue() { + $status1 = new Status(); + $status2 = new Status(); + $message1 = $this->getMockMessage( 'warn1' ); + $message2 = $this->getMockMessage( 'error2' ); + $status1->warning( $message1 ); + $status2->error( $message2 ); + $status2->value = 'FooValue'; + + $status1->merge( $status2, true ); + $this->assertEquals( + 2, + count( $status1->getWarningsArray() ) + count( $status1->getErrorsArray() ) + ); + $this->assertEquals( 'FooValue', $status1->getValue() ); + } + + /** + * @covers Status::hasMessage + */ + public function testHasMessage() { + $status = new Status(); + $status->fatal( 'bad' ); + $status->fatal( wfMessage( 'bad-msg' ) ); + $this->assertTrue( $status->hasMessage( 'bad' ) ); + $this->assertTrue( $status->hasMessage( 'bad-msg' ) ); + $this->assertTrue( $status->hasMessage( wfMessage( 'bad-msg' ) ) ); + $this->assertFalse( $status->hasMessage( 'good' ) ); + } + + /** + * @dataProvider provideCleanParams + * @covers Status::cleanParams + */ + public function testCleanParams( $cleanCallback, $params, $expected ) { + $method = new ReflectionMethod( 'Status', 'cleanParams' ); + $method->setAccessible( true ); + $status = new Status(); + $status->cleanCallback = $cleanCallback; + + $this->assertEquals( $expected, $method->invoke( $status, $params ) ); + } + + public static function provideCleanParams() { + $cleanCallback = function ( $value ) { + return '-' . $value . '-'; + }; + + return array( + array( false, array( 'foo' => 'bar' ), array( 'foo' => 'bar' ) ), + array( $cleanCallback, array( 'foo' => 'bar' ), array( 'foo' => '-bar-' ) ), + ); + } + + /** + * @dataProvider provideGetWikiTextAndHtml + * @covers Status::getWikiText + * @todo test long and short context messages generated through this method + * this can not really be done now due to use of wfMessage()->plain() + * It is possible to mock such methods but only if namespaces are used + */ + public function testGetWikiText( Status $status, $wikitext, $html ) { + $this->assertEquals( $wikitext, $status->getWikiText() ); + } + + /** + * @dataProvider provideGetWikiTextAndHtml + * @covers Status::getHtml + * @todo test long and short context messages generated through this method + * this can not really be done now due to use of $this->getWikiText using + * wfMessage()->plain(). It is possible to mock such methods but only if + * namespaces are used. + */ + public function testGetHtml( Status $status, $wikitext, $html ) { + $this->assertEquals( $html, $status->getHTML() ); + } + + /** + * @return array Array of arrays with values; + * 0 => status object + * 1 => expected string (with no context) + */ + public static function provideGetWikiTextAndHtml() { + $testCases = array(); + + $testCases['GoodStatus'] = array( + new Status(), + "Internal error: Status::getWikiText called for a good result, this is incorrect\n", + "<p>Internal error: Status::getWikiText called for a good result, this is incorrect\n</p>", + ); + + $status = new Status(); + $status->ok = false; + $testCases['GoodButNoError'] = array( + $status, + "Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n", + "<p>Internal error: Status::getWikiText: Invalid result object: no error text but not OK\n</p>", + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $testCases['1StringWarning'] = array( + $status, + "<fooBar!>", + "<p><fooBar!>\n</p>", + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $status->warning( 'fooBar2!' ); + $testCases['2StringWarnings'] = array( + $status, + "* <fooBar!>\n* <fooBar2!>\n", + "<ul><li> <fooBar!></li>\n<li> <fooBar2!></li></ul>\n", + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $testCases['1MessageWarning'] = array( + $status, + "<fooBar!>", + "<p><fooBar!>\n</p>", + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $status->warning( new Message( 'fooBar2!' ) ); + $testCases['2MessageWarnings'] = array( + $status, + "* <fooBar!>\n* <fooBar2!>\n", + "<ul><li> <fooBar!></li>\n<li> <fooBar2!></li></ul>\n", + ); + + return $testCases; + } + + /** + * @dataProvider provideGetMessage + * @covers Status::getMessage + * @todo test long and short context messages generated through this method + */ + public function testGetMessage( Status $status, $expectedParams = array(), $expectedKey ) { + $message = $status->getMessage(); + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $expectedParams, $message->getParams(), 'Message::getParams' ); + $this->assertEquals( $expectedKey, $message->getKey(), 'Message::getKey' ); + } + + /** + * @return array Array of arrays with values; + * 0 => status object + * 1 => expected Message parameters (with no context) + * 2 => expected Message key + */ + public static function provideGetMessage() { + $testCases = array(); + + $testCases['GoodStatus'] = array( + new Status(), + array( "Status::getMessage called for a good result, this is incorrect\n" ), + 'internalerror_info' + ); + + $status = new Status(); + $status->ok = false; + $testCases['GoodButNoError'] = array( + $status, + array( "Status::getMessage: Invalid result object: no error text but not OK\n" ), + 'internalerror_info' + ); + + $status = new Status(); + $status->warning( 'fooBar!' ); + $testCases['1StringWarning'] = array( + $status, + array(), + 'fooBar!' + ); + + // FIXME: Assertion tries to compare a StubUserLang with a Language object, because + // "data providers are executed before both the call to the setUpBeforeClass static method + // and the first call to the setUp method. Because of that you can't access any variables + // you create there from within a data provider." + // http://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html +// $status = new Status(); +// $status->warning( 'fooBar!' ); +// $status->warning( 'fooBar2!' ); +// $testCases[ '2StringWarnings' ] = array( +// $status, +// array( new Message( 'fooBar!' ), new Message( 'fooBar2!' ) ), +// "* \$1\n* \$2" +// ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $testCases['1MessageWarning'] = array( + $status, + array( 'foo', 'bar' ), + 'fooBar!' + ); + + $status = new Status(); + $status->warning( new Message( 'fooBar!', array( 'foo', 'bar' ) ) ); + $status->warning( new Message( 'fooBar2!' ) ); + $testCases['2MessageWarnings'] = array( + $status, + array( new Message( 'fooBar!', array( 'foo', 'bar' ) ), new Message( 'fooBar2!' ) ), + "* \$1\n* \$2" + ); + + return $testCases; + } + + /** + * @covers Status::replaceMessage + */ + public function testReplaceMessage() { + $status = new Status(); + $message = new Message( 'key1', array( 'foo1', 'bar1' ) ); + $status->error( $message ); + $newMessage = new Message( 'key2', array( 'foo2', 'bar2' ) ); + + $status->replaceMessage( $message, $newMessage ); + + $this->assertEquals( $newMessage, $status->errors[0]['message'] ); + } + + /** + * @covers Status::getErrorMessage + */ + public function testGetErrorMessage() { + $method = new ReflectionMethod( 'Status', 'getErrorMessage' ); + $method->setAccessible( true ); + $status = new Status(); + $key = 'foo'; + $params = array( 'bar' ); + + /** @var Message $message */ + $message = $method->invoke( $status, array_merge( array( $key ), $params ) ); + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $key, $message->getKey() ); + $this->assertEquals( $params, $message->getParams() ); + } + + /** + * @covers Status::getErrorMessageArray + */ + public function testGetErrorMessageArray() { + $method = new ReflectionMethod( 'Status', 'getErrorMessageArray' ); + $method->setAccessible( true ); + $status = new Status(); + $key = 'foo'; + $params = array( 'bar' ); + + /** @var Message[] $messageArray */ + $messageArray = $method->invoke( + $status, + array( + array_merge( array( $key ), $params ), + array_merge( array( $key ), $params ) + ) + ); + + $this->assertInternalType( 'array', $messageArray ); + $this->assertCount( 2, $messageArray ); + foreach ( $messageArray as $message ) { + $this->assertInstanceOf( 'Message', $message ); + $this->assertEquals( $key, $message->getKey() ); + $this->assertEquals( $params, $message->getParams() ); + } + } + + /** + * @covers Status::getErrorsByType + */ + public function testGetErrorsByType() { + $status = new Status(); + $warning = new Message( 'warning111' ); + $error = new Message( 'error111' ); + $status->warning( $warning ); + $status->error( $error ); + + $warnings = $status->getErrorsByType( 'warning' ); + $errors = $status->getErrorsByType( 'error' ); + + $this->assertCount( 1, $warnings ); + $this->assertCount( 1, $errors ); + $this->assertEquals( $warning, $warnings[0]['message'] ); + $this->assertEquals( $error, $errors[0]['message'] ); + } + + /** + * @covers Status::__wakeup + */ + public function testWakeUpSanitizesCallback() { + $status = new Status(); + $status->cleanCallback = function ( $value ) { + return '-' . $value . '-'; + }; + $status->__wakeup(); + $this->assertEquals( false, $status->cleanCallback ); + } + + /** + * @dataProvider provideNonObjectMessages + * @covers Status::getStatusArray + */ + public function testGetStatusArrayWithNonObjectMessages( $nonObjMsg ) { + $status = new Status(); + if ( !array_key_exists( 1, $nonObjMsg ) ) { + $status->warning( $nonObjMsg[0] ); + } else { + $status->warning( $nonObjMsg[0], $nonObjMsg[1] ); + } + + $array = $status->getWarningsArray(); // We use getWarningsArray to access getStatusArray + + $this->assertEquals( 1, count( $array ) ); + $this->assertEquals( $nonObjMsg, $array[0] ); + } + + public static function provideNonObjectMessages() { + return array( + array( array( 'ImaString', array( 'param1' => 'value1' ) ) ), + array( array( 'ImaString' ) ), + ); + } + +} diff --git a/tests/phpunit/includes/TemplateCategoriesTest.php b/tests/phpunit/includes/TemplateCategoriesTest.php new file mode 100644 index 00000000..b0d17267 --- /dev/null +++ b/tests/phpunit/includes/TemplateCategoriesTest.php @@ -0,0 +1,96 @@ +<?php + +/** + * @group Database + */ +require __DIR__ . "/../../../maintenance/runJobs.php"; + +class TemplateCategoriesTest extends MediaWikiLangTestCase { + + /** + * @covers Title::getParentCategories + */ + public function testTemplateCategories() { + $user = new User(); + $user->mRights = array( 'createpage', 'edit', 'purge', 'delete' ); + + $title = Title::newFromText( "Categorized from template" ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + new WikitextContent( '{{Categorising template}}' ), + 'Create a page with a template', + 0, + false, + $user + ); + + $this->assertEquals( + array(), + $title->getParentCategories(), + 'Verify that the category doesn\'t contain the page before the template is created' + ); + + // Create template + $template = WikiPage::factory( Title::newFromText( 'Template:Categorising template' ) ); + $template->doEditContent( + new WikitextContent( '[[Category:Solved bugs]]' ), + 'Add a category through a template', + 0, + false, + $user + ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure page is in the category + $this->assertEquals( + array( 'Category:Solved_bugs' => $title->getPrefixedText() ), + $title->getParentCategories(), + 'Verify that the page is in the category after the template is created' + ); + + // Edit the template + $template->doEditContent( + new WikitextContent( '[[Category:Solved bugs 2]]' ), + 'Change the category added by the template', + 0, + false, + $user + ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure page is in the right category + $this->assertEquals( + array( 'Category:Solved_bugs_2' => $title->getPrefixedText() ), + $title->getParentCategories(), + 'Verify that the page is in the right category after the template is edited' + ); + + // Now delete the template + $error = ''; + $template->doDeleteArticleReal( 'Delete the template', false, 0, true, $error, $user ); + + // Run the job queue + JobQueueGroup::destroySingletons(); + $jobs = new RunJobs; + $jobs->loadParamsAndArgs( null, array( 'quiet' => true ), null ); + $jobs->execute(); + + // Make sure the page is no longer in the category + $this->assertEquals( + array(), + $title->getParentCategories(), + 'Verify that the page is no longer in the category after template deletion' + ); + + } +} diff --git a/tests/phpunit/includes/TestUser.php b/tests/phpunit/includes/TestUser.php new file mode 100644 index 00000000..610a6acd --- /dev/null +++ b/tests/phpunit/includes/TestUser.php @@ -0,0 +1,62 @@ +<?php + +/** + * Wraps the user object, so we can also retain full access to properties + * like password if we log in via the API. + */ +class TestUser { + public $username; + public $password; + public $email; + public $groups; + public $user; + + public function __construct( $username, $realname = 'Real Name', + $email = 'sample@example.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 ); + + // Adjust groups by adding any missing ones and removing any extras + $currentGroups = $this->user->getGroups(); + foreach ( array_diff( $this->groups, $currentGroups ) as $group ) { + $this->user->addGroup( $group ); + } + foreach ( array_diff( $currentGroups, $this->groups ) as $group ) { + $this->user->removeGroup( $group ); + } + $this->user->saveSettings(); + } +} diff --git a/tests/phpunit/includes/TimeAdjustTest.php b/tests/phpunit/includes/TimeAdjustTest.php new file mode 100644 index 00000000..ae82bc40 --- /dev/null +++ b/tests/phpunit/includes/TimeAdjustTest.php @@ -0,0 +1,39 @@ +<?php + +class TimeAdjustTest extends MediaWikiLangTestCase { + protected function setUp() { + parent::setUp(); + } + + /** + * Test offset usage for a given Language::userAdjust + * @dataProvider dataUserAdjust + * @covers Language::userAdjust + */ + public function testUserAdjust( $date, $localTZoffset, $expected ) { + global $wgContLang; + + $this->setMwGlobals( 'wgLocalTZoffset', $localTZoffset ); + + $this->assertEquals( + $expected, + strval( $wgContLang->userAdjust( $date, '' ) ), + "User adjust {$date} by {$localTZoffset} minutes should give {$expected}" + ); + } + + public static function dataUserAdjust() { + return array( + array( '20061231235959', 0, '20061231235959' ), + array( '20061231235959', 5, '20070101000459' ), + array( '20061231235959', 15, '20070101001459' ), + array( '20061231235959', 60, '20070101005959' ), + array( '20061231235959', 90, '20070101012959' ), + array( '20061231235959', 120, '20070101015959' ), + array( '20061231235959', 540, '20070101085959' ), + array( '20061231235959', -5, '20061231235459' ), + array( '20061231235959', -30, '20061231232959' ), + array( '20061231235959', -60, '20061231225959' ), + ); + } +} diff --git a/tests/phpunit/includes/TitleArrayFromResultTest.php b/tests/phpunit/includes/TitleArrayFromResultTest.php new file mode 100644 index 00000000..0f7069ae --- /dev/null +++ b/tests/phpunit/includes/TitleArrayFromResultTest.php @@ -0,0 +1,119 @@ +<?php + +/** + * @author Adam Shorland + * @covers TitleArrayFromResult + */ +class TitleArrayFromResultTest extends MediaWikiTestCase { + + private function getMockResultWrapper( $row = null, $numRows = 1 ) { + $resultWrapper = $this->getMockBuilder( 'ResultWrapper' ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithTitle( $namespace = 3, $title = 'foo' ) { + $row = new stdClass(); + $row->page_namespace = $namespace; + $row->page_title = $title; + return $row; + } + + private function getTitleArrayFromResult( $resultWrapper ) { + return new TitleArrayFromResult( $resultWrapper ); + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getTitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getTitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( 'Title', $object->current ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public static function provideNumberOfRows() { + return array( + array( 0 ), + array( 1 ), + array( 122 ), + ); + } + + /** + * @dataProvider provideNumberOfRows + * @covers TitleArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithTitle(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers TitleArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $row ) ); + $this->assertInstanceOf( 'Title', $object->current() ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public function provideTestValid() { + return array( + array( $this->getRowWithTitle(), true ), + array( false, false ), + ); + } + + /** + * @dataProvider provideTestValid + * @covers TitleArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = $this->getTitleArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + //@todo unit test for key() + //@todo unit test for next() + //@todo unit test for rewind() +} diff --git a/tests/phpunit/includes/TitleMethodsTest.php b/tests/phpunit/includes/TitleMethodsTest.php new file mode 100644 index 00000000..5904facd --- /dev/null +++ b/tests/phpunit/includes/TitleMethodsTest.php @@ -0,0 +1,300 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * + * @note We don't make assumptions about the main namespace. + * But we do expect the Help namespace to contain Wikitext. + */ +class TitleMethodsTest extends MediaWikiTestCase { + + protected function setUp() { + global $wgContLang; + + parent::setUp(); + + $this->mergeMwGlobalArrayValue( + 'wgExtraNamespaces', + array( + 12302 => 'TEST-JS', + 12303 => 'TEST-JS_TALK', + ) + ); + + $this->mergeMwGlobalArrayValue( + 'wgNamespaceContentModels', + array( + 12302 => CONTENT_MODEL_JAVASCRIPT, + ) + ); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function tearDown() { + global $wgContLang; + + parent::tearDown(); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + public static function provideEquals() { + return array( + array( 'Main Page', 'Main Page', true ), + array( 'Main Page', 'Not The Main Page', false ), + array( 'Main Page', 'Project:Main Page', false ), + array( 'File:Example.png', 'Image:Example.png', true ), + array( 'Special:Version', 'Special:Version', true ), + array( 'Special:Version', 'Special:Recentchanges', false ), + array( 'Special:Version', 'Main Page', false ), + ); + } + + /** + * @dataProvider provideEquals + * @covers Title::equals + */ + public function testEquals( $titleA, $titleB, $expectedBool ) { + $titleA = Title::newFromText( $titleA ); + $titleB = Title::newFromText( $titleB ); + + $this->assertEquals( $expectedBool, $titleA->equals( $titleB ) ); + $this->assertEquals( $expectedBool, $titleB->equals( $titleA ) ); + } + + public static function provideInNamespace() { + return array( + array( 'Main Page', NS_MAIN, true ), + array( 'Main Page', NS_TALK, false ), + array( 'Main Page', NS_USER, false ), + array( 'User:Foo', NS_USER, true ), + array( 'User:Foo', NS_USER_TALK, false ), + array( 'User:Foo', NS_TEMPLATE, false ), + array( 'User_talk:Foo', NS_USER_TALK, true ), + array( 'User_talk:Foo', NS_USER, false ), + ); + } + + /** + * @dataProvider provideInNamespace + * @covers Title::inNamespace + */ + public function testInNamespace( $title, $ns, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->inNamespace( $ns ) ); + } + + /** + * @covers Title::inNamespaces + */ + public function testInNamespaces() { + $mainpage = Title::newFromText( 'Main Page' ); + $this->assertTrue( $mainpage->inNamespaces( NS_MAIN, NS_USER ) ); + $this->assertTrue( $mainpage->inNamespaces( array( NS_MAIN, NS_USER ) ) ); + $this->assertTrue( $mainpage->inNamespaces( array( NS_USER, NS_MAIN ) ) ); + $this->assertFalse( $mainpage->inNamespaces( array( NS_PROJECT, NS_TEMPLATE ) ) ); + } + + public static function provideHasSubjectNamespace() { + return array( + array( 'Main Page', NS_MAIN, true ), + array( 'Main Page', NS_TALK, true ), + array( 'Main Page', NS_USER, false ), + array( 'User:Foo', NS_USER, true ), + array( 'User:Foo', NS_USER_TALK, true ), + array( 'User:Foo', NS_TEMPLATE, false ), + array( 'User_talk:Foo', NS_USER_TALK, true ), + array( 'User_talk:Foo', NS_USER, true ), + ); + } + + /** + * @dataProvider provideHasSubjectNamespace + * @covers Title::hasSubjectNamespace + */ + public function testHasSubjectNamespace( $title, $ns, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->hasSubjectNamespace( $ns ) ); + } + + public function dataGetContentModel() { + return array( + array( 'Help:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'TEST-JS:Foo', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'TEST-JS_TALK:Foo.js', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetContentModel + * @covers Title::getContentModel + */ + public function testGetContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, $title->getContentModel() ); + } + + /** + * @dataProvider dataGetContentModel + * @covers Title::hasContentModel + */ + public function testHasContentModel( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertTrue( $title->hasContentModel( $expectedModelId ) ); + } + + public static function provideIsCssOrJsPage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.js', false ), + array( 'Help:Foo/bar.js', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', false ), + array( 'User talk:Foo/bar.css', false ), + array( 'User:Foo/bar.js.xxx', false ), + array( 'User:Foo/bar.xxx', false ), + array( 'MediaWiki:Foo.js', true ), + array( 'MediaWiki:Foo.css', true ), + array( 'MediaWiki:Foo.JS', false ), + array( 'MediaWiki:Foo.CSS', false ), + array( 'MediaWiki:Foo.css.xxx', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + ); + } + + /** + * @dataProvider provideIsCssOrJsPage + * @covers Title::isCssOrJsPage + */ + public function testIsCssOrJsPage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssOrJsPage() ); + } + + public static function provideIsCssJsSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.js', false ), + array( 'Help:Foo/bar.js', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo/bar.js', true ), + array( 'User:Foo/bar.css', true ), + array( 'User talk:Foo/bar.css', false ), + array( 'User:Foo/bar.js.xxx', false ), + array( 'User:Foo/bar.xxx', false ), + array( 'MediaWiki:Foo.js', false ), + array( 'User:Foo/bar.JS', false ), + array( 'User:Foo/bar.CSS', false ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + ); + } + + /** + * @dataProvider provideIsCssJsSubpage + * @covers Title::isCssJsSubpage + */ + public function testIsCssJsSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssJsSubpage() ); + } + + public static function provideIsCssSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.css', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo.css', false ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', true ), + ); + } + + /** + * @dataProvider provideIsCssSubpage + * @covers Title::isCssSubpage + */ + public function testIsCssSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isCssSubpage() ); + } + + public static function provideIsJsSubpage() { + return array( + array( 'Help:Foo', false ), + array( 'Help:Foo.css', false ), + array( 'User:Foo', false ), + array( 'User:Foo.js', false ), + array( 'User:Foo.css', false ), + array( 'User:Foo/bar.js', true ), + array( 'User:Foo/bar.css', false ), + ); + } + + /** + * @dataProvider provideIsJsSubpage + * @covers Title::isJsSubpage + */ + public function testIsJsSubpage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isJsSubpage() ); + } + + public static function provideIsWikitextPage() { + return array( + array( 'Help:Foo', true ), + array( 'Help:Foo.js', true ), + array( 'Help:Foo/bar.js', true ), + array( 'User:Foo', true ), + array( 'User:Foo.js', true ), + array( 'User:Foo/bar.js', false ), + array( 'User:Foo/bar.css', false ), + array( 'User talk:Foo/bar.css', true ), + array( 'User:Foo/bar.js.xxx', true ), + array( 'User:Foo/bar.xxx', true ), + array( 'MediaWiki:Foo.js', false ), + array( 'MediaWiki:Foo.css', false ), + array( 'MediaWiki:Foo/bar.css', false ), + array( 'User:Foo/bar.JS', true ), + array( 'User:Foo/bar.CSS', true ), + array( 'TEST-JS:Foo', false ), + array( 'TEST-JS:Foo.js', false ), + array( 'TEST-JS_TALK:Foo.js', true ), + ); + } + + /** + * @dataProvider provideIsWikitextPage + * @covers Title::isWikitextPage + */ + public function testIsWikitextPage( $title, $expectedBool ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedBool, $title->isWikitextPage() ); + } +} diff --git a/tests/phpunit/includes/TitlePermissionTest.php b/tests/phpunit/includes/TitlePermissionTest.php new file mode 100644 index 00000000..d2400b3f --- /dev/null +++ b/tests/phpunit/includes/TitlePermissionTest.php @@ -0,0 +1,770 @@ +<?php + +/** + * @group Database + * + * @covers Title::getUserPermissionsErrors + * @covers Title::getUserPermissionsErrorsInternal + */ +class TitlePermissionTest extends MediaWikiLangTestCase { + + /** + * @var string + */ + protected $userName, $altUserName; + + /** + * @var Title + */ + protected $title; + + /** + * @var User + */ + protected $user, $anonUser, $userUser, $altUser; + + protected function setUp() { + parent::setUp(); + + $langObj = Language::factory( 'en' ); + $localZone = 'UTC'; + $localOffset = date( 'Z' ) / 60; + + $this->setMwGlobals( array( + 'wgMemc' => new EmptyBagOStuff, + 'wgContLang' => $langObj, + 'wgLanguageCode' => 'en', + 'wgLang' => $langObj, + 'wgLocaltimezone' => $localZone, + 'wgLocalTZoffset' => $localOffset, + 'wgNamespaceProtection' => array( + NS_MEDIAWIKI => 'editinterface', + ), + ) ); + // Without this testUserBlock will use a non-English context on non-English MediaWiki + // installations (because of how Title::checkUserBlock is implemented) and fail. + RequestContext::resetMain(); + + $this->userName = 'Useruser'; + $this->altUserName = 'Altuseruser'; + date_default_timezone_set( $localZone ); + + $this->title = Title::makeTitle( NS_MAIN, "Main Page" ); + if ( !isset( $this->userUser ) || !( $this->userUser instanceof User ) ) { + $this->userUser = User::newFromName( $this->userName ); + + if ( !$this->userUser->getID() ) { + $this->userUser = User::createNew( $this->userName, array( + "email" => "test@example.com", + "real_name" => "Test User" ) ); + $this->userUser->load(); + } + + $this->altUser = User::newFromName( $this->altUserName ); + if ( !$this->altUser->getID() ) { + $this->altUser = User::createNew( $this->altUserName, array( + "email" => "alttest@example.com", + "real_name" => "Test User Alt" ) ); + $this->altUser->load(); + } + + $this->anonUser = User::newFromId( 0 ); + + $this->user = $this->userUser; + } + } + + protected function setUserPerm( $perm ) { + // Setting member variables is evil!!! + + if ( is_array( $perm ) ) { + $this->user->mRights = $perm; + } else { + $this->user->mRights = array( $perm ); + } + } + + protected function setTitle( $ns, $title = "Main_Page" ) { + $this->title = Title::makeTitle( $ns, $title ); + } + + protected function setUser( $userName = null ) { + if ( $userName === 'anon' ) { + $this->user = $this->anonUser; + } elseif ( $userName === null || $userName === $this->userName ) { + $this->user = $this->userUser; + } else { + $this->user = $this->altUser; + } + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testQuickPermissions() { + global $wgContLang; + $prefix = $wgContLang->getFormattedNsText( NS_PROJECT ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( "nocreatetext" ) ), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreatetext' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreatetext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_TALK ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createpage" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "createtalk" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'create', $this->user ); + $this->assertEquals( array( array( 'nocreate-loggedin' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'cant-move-user-page' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'cant-move-user-page' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_USER, $this->userName . '/subpage' ); + $this->setUserPerm( "move-rootuserpages" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ), $res ); + + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "movefile" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowed' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenotallowedfile' ), array( 'movenologintext' ) ), $res ); + + $this->setTitle( NS_FILE, "img.png" ); + $this->setUserPerm( "movefile" ); + $res = $this->title->getUserPermissionsErrors( 'move', $this->user ); + $this->assertEquals( array( array( 'movenologintext' ) ), $res ); + + $this->setUser( $this->userName ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowedfile' ) ) ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( + 'move', + array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ) + ); + + $this->setUser( 'anon' ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowedfile' ) ) ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( + 'move', + array( array( 'movenotallowedfile' ), array( 'movenotallowed' ) ), + array( array( 'movenotallowedfile' ), array( 'movenologintext' ) ) + ); + + if ( $this->isWikitextNS( NS_MAIN ) ) { + //NOTE: some content models don't allow moving + // @todo find a Wikitext namespace for testing + + $this->setTitle( NS_MAIN ); + $this->setUser( 'anon' ); + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array() ); + + $this->setUserPerm( "" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowed' ) ), + array( array( 'movenologintext' ) ) ); + + $this->setUser( $this->userName ); + $this->setUserPerm( "" ); + $this->runGroupPermissions( 'move', array( array( 'movenotallowed' ) ) ); + + $this->setUserPerm( "move" ); + $this->runGroupPermissions( 'move', array() ); + + $this->setUser( 'anon' ); + $this->setUserPerm( 'move' ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( '' ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array( array( 'movenotallowed' ) ), $res ); + } + + $this->setTitle( NS_USER ); + $this->setUser( $this->userName ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( "move" ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array( array( 'cant-move-to-user-page' ) ), $res ); + + $this->setUser( 'anon' ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setTitle( NS_USER, "User/subpage" ); + $this->setUserPerm( array( "move", "move-rootuserpages" ) ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUserPerm( "move" ); + $res = $this->title->getUserPermissionsErrors( 'move-target', $this->user ); + $this->assertEquals( array(), $res ); + + $this->setUser( 'anon' ); + $check = array( + 'edit' => array( + array( array( 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ) ), + array( array( 'badaccess-group0' ) ), + array(), + true + ), + 'protect' => array( + array( array( + 'badaccess-groups', + "[[$prefix:Administrators|Administrators]]", 1 ), + array( 'protect-cantedit' + ) ), + array( array( 'badaccess-group0' ), array( 'protect-cantedit' ) ), + array( array( 'protect-cantedit' ) ), + false + ), + '' => array( array(), array(), array(), true ) + ); + + foreach ( array( "edit", "protect", "" ) as $action ) { + $this->setUserPerm( null ); + $this->assertEquals( $check[$action][0], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + + global $wgGroupPermissions; + $old = $wgGroupPermissions; + $wgGroupPermissions = array(); + + $this->assertEquals( $check[$action][1], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + $wgGroupPermissions = $old; + + $this->setUserPerm( $action ); + $this->assertEquals( $check[$action][2], + $this->title->getUserPermissionsErrors( $action, $this->user, true ) ); + + $this->setUserPerm( $action ); + $this->assertEquals( $check[$action][3], + $this->title->userCan( $action, $this->user, true ) ); + $this->assertEquals( $check[$action][3], + $this->title->quickUserCan( $action, $this->user ) ); + # count( User::getGroupsWithPermissions( $action ) ) < 1 + } + } + + protected function runGroupPermissions( $action, $result, $result2 = null ) { + global $wgGroupPermissions; + + if ( $result2 === null ) { + $result2 = $result; + } + + $wgGroupPermissions['autoconfirmed']['move'] = false; + $wgGroupPermissions['user']['move'] = false; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = true; + $wgGroupPermissions['user']['move'] = false; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = true; + $wgGroupPermissions['user']['move'] = true; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + + $wgGroupPermissions['autoconfirmed']['move'] = false; + $wgGroupPermissions['user']['move'] = true; + $res = $this->title->getUserPermissionsErrors( $action, $this->user ); + $this->assertEquals( $result2, $res ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testSpecialsAndNSPermissions() { + global $wgNamespaceProtection; + $this->setUser( $this->userName ); + + $this->setTitle( NS_SPECIAL ); + + $this->assertEquals( array( array( 'badaccess-group0' ), array( 'ns-specialprotected' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MAIN ); + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $wgNamespaceProtection[NS_USER] = array( 'bogus' ); + + $this->setTitle( NS_USER ); + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ), array( 'namespaceprotected', 'User', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MEDIAWIKI ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array( array( 'protectedinterface', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->setTitle( NS_MEDIAWIKI ); + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array( array( 'protectedinterface', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $wgNamespaceProtection = null; + + $this->setUserPerm( 'bogus' ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'bogus', $this->user ) ); + + $this->setUserPerm( '' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'bogus', $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testCssAndJavascriptPermissions() { + $this->setUser( $this->userName ); + + $this->setTitle( NS_USER, $this->userName . '/test.js' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ) + ); + + $this->setTitle( NS_USER, $this->userName . '/test.css' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'mycustomcssprotected', 'bogus' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/test.js' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customjsprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/test.css' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ), array( 'customcssprotected', 'bogus' ) ) + ); + + $this->setTitle( NS_USER, $this->altUserName . '/tempo' ); + $this->runCSSandJSPermissions( + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ), + array( array( 'badaccess-group0' ) ) + ); + } + + protected function runCSSandJSPermissions( $result0, $result1, $result2, $result3, $result4 ) { + $this->setUserPerm( '' ); + $this->assertEquals( $result0, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editmyusercss' ); + $this->assertEquals( $result1, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editmyuserjs' ); + $this->assertEquals( $result2, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editusercss' ); + $this->assertEquals( $result3, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'edituserjs' ); + $this->assertEquals( $result4, + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( 'editusercssjs' ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + + $this->setUserPerm( array( 'edituserjs', 'editusercss' ) ); + $this->assertEquals( array( array( 'badaccess-group0' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testPageRestrictions() { + global $wgContLang; + + $prefix = $wgContLang->getFormattedNsText( NS_PROJECT ); + + $this->setTitle( NS_MAIN ); + $this->title->mRestrictionsLoaded = true; + $this->setUserPerm( "edit" ); + $this->title->mRestrictions = array( "bogus" => array( 'bogus', "sysop", "protect", "" ) ); + + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->assertEquals( true, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->title->mRestrictions = array( "edit" => array( 'bogus', "sysop", "protect", "" ), + "bogus" => array( 'bogus', "sysop", "protect", "" ) ); + + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + $this->setUserPerm( "" ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'badaccess-groups', "*, [[$prefix:Users|Users]]", 2 ), + array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + $this->setUserPerm( array( "edit", "editprotected" ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( + array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->title->mCascadeRestriction = true; + $this->setUserPerm( "edit" ); + $this->assertEquals( false, + $this->title->quickUserCan( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'editprotected', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'editprotected', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + + $this->setUserPerm( array( "edit", "editprotected" ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'bogus', $this->user ) ); + $this->assertEquals( false, + $this->title->quickUserCan( 'edit', $this->user ) ); + $this->assertEquals( array( array( 'badaccess-group0' ), + array( 'protectedpagetext', 'bogus', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ), + array( 'protectedpagetext', 'protect', 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', + $this->user ) ); + $this->assertEquals( array( array( 'protectedpagetext', 'bogus', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ), + array( 'protectedpagetext', 'protect', 'edit' ) ), + $this->title->getUserPermissionsErrors( 'edit', + $this->user ) ); + } + + public function testCascadingSourcesRestrictions() { + $this->setTitle( NS_MAIN, "test page" ); + $this->setUserPerm( array( "edit", "bogus" ) ); + + $this->title->mCascadeSources = array( + Title::makeTitle( NS_MAIN, "Bogus" ), + Title::makeTitle( NS_MAIN, "UnBogus" ) + ); + $this->title->mCascadingRestrictions = array( + "bogus" => array( 'bogus', "sysop", "protect", "" ) + ); + + $this->assertEquals( false, + $this->title->userCan( 'bogus', $this->user ) ); + $this->assertEquals( array( array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ), + array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ), + array( "cascadeprotected", 2, "* [[:Bogus]]\n* [[:UnBogus]]\n", 'bogus' ) ), + $this->title->getUserPermissionsErrors( 'bogus', $this->user ) ); + + $this->assertEquals( true, + $this->title->userCan( 'edit', $this->user ) ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'edit', $this->user ) ); + } + + /** + * @todo This test method should be split up into separate test methods and + * data providers + */ + public function testActionPermissions() { + $this->setUserPerm( array( "createpage" ) ); + $this->setTitle( NS_MAIN, "test page" ); + $this->title->mTitleProtection['pt_create_perm'] = ''; + $this->title->mTitleProtection['pt_user'] = $this->user->getID(); + $this->title->mTitleProtection['pt_expiry'] = wfGetDB( DB_SLAVE )->getInfinity(); + $this->title->mTitleProtection['pt_reason'] = 'test'; + $this->title->mCascadeRestriction = false; + + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->title->mTitleProtection['pt_create_perm'] = 'sysop'; + $this->setUserPerm( array( 'createpage', 'protect' ) ); + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->setUserPerm( array( 'createpage', 'editprotected' ) ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'create', $this->user ) ); + + $this->setUserPerm( array( 'createpage' ) ); + $this->assertEquals( array( array( 'titleprotected', 'Useruser', 'test' ) ), + $this->title->getUserPermissionsErrors( 'create', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'create', $this->user ) ); + + $this->setTitle( NS_MEDIA, "test page" ); + $this->setUserPerm( array( "move" ) ); + $this->assertEquals( false, + $this->title->userCan( 'move', $this->user ) ); + $this->assertEquals( array( array( 'immobile-source-namespace', 'Media' ) ), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + + $this->setTitle( NS_HELP, "test page" ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'move', $this->user ) ); + + $this->title->mInterwiki = "no"; + $this->assertEquals( array( array( 'immobile-source-page' ) ), + $this->title->getUserPermissionsErrors( 'move', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'move', $this->user ) ); + + $this->setTitle( NS_MEDIA, "test page" ); + $this->assertEquals( false, + $this->title->userCan( 'move-target', $this->user ) ); + $this->assertEquals( array( array( 'immobile-target-namespace', 'Media' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + + $this->setTitle( NS_HELP, "test page" ); + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $this->assertEquals( true, + $this->title->userCan( 'move-target', $this->user ) ); + + $this->title->mInterwiki = "no"; + $this->assertEquals( array( array( 'immobile-target-page' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $this->assertEquals( false, + $this->title->userCan( 'move-target', $this->user ) ); + } + + public function testUserBlock() { + global $wgEmailConfirmToEdit, $wgEmailAuthentication; + $wgEmailConfirmToEdit = true; + $wgEmailAuthentication = true; + + $this->setUserPerm( array( "createpage", "move" ) ); + $this->setTitle( NS_HELP, "test page" ); + + # $short + $this->assertEquals( array( array( 'confirmedittext' ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + $wgEmailConfirmToEdit = false; + $this->assertEquals( true, $this->title->userCan( 'move-target', $this->user ) ); + + # $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount' + $this->assertEquals( array(), + $this->title->getUserPermissionsErrors( 'move-target', + $this->user ) ); + + global $wgLang; + $prev = time(); + $now = time() + 120; + $this->user->mBlockedby = $this->user->getId(); + $this->user->mBlock = new Block( '127.0.8.1', 0, $this->user->getId(), + 'no reason given', $prev + 3600, 1, 0 ); + $this->user->mBlock->mTimestamp = 0; + $this->assertEquals( array( array( 'autoblockedtext', + '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', + 'Useruser', null, 'infinite', '127.0.8.1', + $wgLang->timeanddate( wfTimestamp( TS_MW, $prev ), true ) ) ), + $this->title->getUserPermissionsErrors( 'move-target', + $this->user ) ); + + $this->assertEquals( false, $this->title->userCan( 'move-target', $this->user ) ); + // quickUserCan should ignore user blocks + $this->assertEquals( true, $this->title->quickUserCan( 'move-target', $this->user ) ); + + global $wgLocalTZoffset; + $wgLocalTZoffset = -60; + $this->user->mBlockedby = $this->user->getName(); + $this->user->mBlock = new Block( '127.0.8.1', 0, $this->user->getId(), + 'no reason given', $now, 0, 10 ); + $this->assertEquals( array( array( 'blockedtext', + '[[User:Useruser|Useruser]]', 'no reason given', '127.0.0.1', + 'Useruser', null, '23:00, 31 December 1969', '127.0.8.1', + $wgLang->timeanddate( wfTimestamp( TS_MW, $now ), true ) ) ), + $this->title->getUserPermissionsErrors( 'move-target', $this->user ) ); + # $action != 'read' && $action != 'createaccount' && $user->isBlockedFrom( $this ) + # $user->blockedFor() == '' + # $user->mBlock->mExpiry == 'infinity' + } +} diff --git a/tests/phpunit/includes/TitleTest.php b/tests/phpunit/includes/TitleTest.php new file mode 100644 index 00000000..fb58381f --- /dev/null +++ b/tests/phpunit/includes/TitleTest.php @@ -0,0 +1,650 @@ +<?php + +/** + * @group Title + */ +class TitleTest extends MediaWikiTestCase { + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ), + // User language + 'wgLang' => Language::factory( 'en' ), + 'wgAllowUserJs' => false, + 'wgDefaultLanguageVariant' => false, + ) ); + } + + /** + * @covers Title::legalChars + */ + public function testLegalChars() { + $titlechars = Title::legalChars(); + + foreach ( range( 1, 255 ) as $num ) { + $chr = chr( $num ); + if ( strpos( "#[]{}<>|", $chr ) !== false || preg_match( "/[\\x00-\\x1f\\x7f]/", $chr ) ) { + $this->assertFalse( + (bool)preg_match( "/[$titlechars]/", $chr ), + "chr($num) = $chr is not a valid titlechar" + ); + } else { + $this->assertTrue( + (bool)preg_match( "/[$titlechars]/", $chr ), + "chr($num) = $chr is a valid titlechar" + ); + } + } + } + + public static function provideValidSecureAndSplit() { + return array( + array( 'Sandbox' ), + array( 'A "B"' ), + array( 'A \'B\'' ), + array( '.com' ), + array( '~' ), + array( '#' ), + array( '"' ), + array( '\'' ), + array( 'Talk:Sandbox' ), + array( 'Talk:Foo:Sandbox' ), + array( 'File:Example.svg' ), + array( 'File_talk:Example.svg' ), + array( 'Foo/.../Sandbox' ), + array( 'Sandbox/...' ), + array( 'A~~' ), + array( ':A' ), + // Length is 256 total, but only title part matters + array( 'Category:' . str_repeat( 'x', 248 ) ), + array( str_repeat( 'x', 252 ) ), + // interwiki prefix + array( 'localtestiw: #anchor' ), + array( 'localtestiw:' ), + array( 'localtestiw:foo' ), + array( 'localtestiw: foo # anchor' ), + array( 'localtestiw: Talk: Sandbox # anchor' ), + array( 'remotetestiw:' ), + array( 'remotetestiw: Talk: # anchor' ), + array( 'remotetestiw: #bar' ), + array( 'remotetestiw: Talk:' ), + array( 'remotetestiw: Talk: Foo' ), + array( 'localtestiw:remotetestiw:' ), + array( 'localtestiw:remotetestiw:foo' ) + ); + } + + public static function provideInvalidSecureAndSplit() { + return array( + array( '' ), + array( ':' ), + array( '__ __' ), + array( ' __ ' ), + // Bad characters forbidden regardless of wgLegalTitleChars + array( 'A [ B' ), + array( 'A ] B' ), + array( 'A { B' ), + array( 'A } B' ), + array( 'A < B' ), + array( 'A > B' ), + array( 'A | B' ), + // URL encoding + array( 'A%20B' ), + array( 'A%23B' ), + array( 'A%2523B' ), + // XML/HTML character entity references + // Note: Commented out because they are not marked invalid by the PHP test as + // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first. + //'A é B', + //'A é B', + //'A é B', + // Subject of NS_TALK does not roundtrip to NS_MAIN + array( 'Talk:File:Example.svg' ), + // Directory navigation + array( '.' ), + array( '..' ), + array( './Sandbox' ), + array( '../Sandbox' ), + array( 'Foo/./Sandbox' ), + array( 'Foo/../Sandbox' ), + array( 'Sandbox/.' ), + array( 'Sandbox/..' ), + // Tilde + array( 'A ~~~ Name' ), + array( 'A ~~~~ Signature' ), + array( 'A ~~~~~ Timestamp' ), + array( str_repeat( 'x', 256 ) ), + // Namespace prefix without actual title + array( 'Talk:' ), + array( 'Talk:#' ), + array( 'Category: ' ), + array( 'Category: #bar' ), + // interwiki prefix + array( 'localtestiw: Talk: # anchor' ), + array( 'localtestiw: Talk:' ) + ); + } + + private function secureAndSplitGlobals() { + $this->setMwGlobals( array( + 'wgLocalInterwikis' => array( 'localtestiw' ), + 'wgHooks' => array( + 'InterwikiLoadPrefix' => array( + function ( $prefix, &$data ) { + if ( $prefix === 'localtestiw' ) { + $data = array( 'iw_url' => 'localtestiw' ); + } elseif ( $prefix === 'remotetestiw' ) { + $data = array( 'iw_url' => 'remotetestiw' ); + } + return false; + } + ) + ) + )); + } + + /** + * See also mediawiki.Title.test.js + * @covers Title::secureAndSplit + * @dataProvider provideValidSecureAndSplit + * @note This mainly tests MediaWikiTitleCodec::parseTitle(). + */ + public function testSecureAndSplitValid( $text ) { + $this->secureAndSplitGlobals(); + $this->assertInstanceOf( 'Title', Title::newFromText( $text ), "Valid: $text" ); + } + + /** + * See also mediawiki.Title.test.js + * @covers Title::secureAndSplit + * @dataProvider provideInvalidSecureAndSplit + * @note This mainly tests MediaWikiTitleCodec::parseTitle(). + */ + public function testSecureAndSplitInvalid( $text ) { + $this->secureAndSplitGlobals(); + $this->assertNull( Title::newFromText( $text ), "Invalid: $text" ); + } + + public static function provideConvertByteClassToUnicodeClass() { + return array( + array( + ' %!"$&\'()*,\\-.\\/0-9:;=?@A-Z\\\\^_`a-z~\\x80-\\xFF+', + ' %!"$&\'()*,\\-./0-9:;=?@A-Z\\\\\\^_`a-z~+\\u0080-\\uFFFF', + ), + array( + 'QWERTYf-\\xFF+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\xFD+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTYf-y+', + 'QWERTYf-y+', + ), + array( + 'QWERTYf-\\x80+', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\x80+\\x23', + 'QWERTYf-\\x7F+#\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\x66-\\x80+\\xD3', + 'QWERTYf-\\x7F+\\u0080-\\uFFFF', + ), + array( + '\\\\\\x99', + '\\\\\\u0080-\\uFFFF', + ), + array( + '-\\x99', + '\\-\\u0080-\\uFFFF', + ), + array( + 'QWERTY\\-\\x99', + 'QWERTY\\-\\u0080-\\uFFFF', + ), + array( + '\\\\x99', + '\\\\x99', + ), + array( + 'A-\\x9F', + 'A-\\x7F\\u0080-\\uFFFF', + ), + array( + '\\x66-\\x77QWERTY\\x88-\\x91FXZ', + 'f-wQWERTYFXZ\\u0080-\\uFFFF', + ), + array( + '\\x66-\\x99QWERTY\\xAA-\\xEEFXZ', + 'f-\\x7FQWERTYFXZ\\u0080-\\uFFFF', + ), + ); + } + + /** + * @dataProvider provideConvertByteClassToUnicodeClass + * @covers Title::convertByteClassToUnicodeClass + */ + public function testConvertByteClassToUnicodeClass( $byteClass, $unicodeClass ) { + $this->assertEquals( $unicodeClass, Title::convertByteClassToUnicodeClass( $byteClass ) ); + } + + /** + * @dataProvider provideSpecialNamesWithAndWithoutParameter + * @covers Title::fixSpecialName + */ + public function testFixSpecialNameRetainsParameter( $text, $expectedParam ) { + $title = Title::newFromText( $text ); + $fixed = $title->fixSpecialName(); + $stuff = explode( '/', $fixed->getDBkey(), 2 ); + if ( count( $stuff ) == 2 ) { + $par = $stuff[1]; + } else { + $par = null; + } + $this->assertEquals( + $expectedParam, + $par, + "Bug 31100 regression check: Title->fixSpecialName() should preserve parameter" + ); + } + + public static function provideSpecialNamesWithAndWithoutParameter() { + return array( + array( 'Special:Version', null ), + array( 'Special:Version/', '' ), + array( 'Special:Version/param', 'param' ), + ); + } + + /** + * Auth-less test of Title::isValidMoveOperation + * + * @group Database + * @param string $source + * @param string $target + * @param array|string|bool $expected Required error + * @dataProvider provideTestIsValidMoveOperation + * @covers Title::isValidMoveOperation + * @covers Title::validateFileMoveOperation + */ + public function testIsValidMoveOperation( $source, $target, $expected ) { + $this->setMwGlobals( 'wgContentHandlerUseDB', false ); + $title = Title::newFromText( $source ); + $nt = Title::newFromText( $target ); + $errors = $title->isValidMoveOperation( $nt, false ); + if ( $expected === true ) { + $this->assertTrue( $errors ); + } else { + $errors = $this->flattenErrorsArray( $errors ); + foreach ( (array)$expected as $error ) { + $this->assertContains( $error, $errors ); + } + } + } + + public static function provideTestIsValidMoveOperation() { + return array( + // for Title::isValidMoveOperation + array( 'Some page', '', 'badtitletext' ), + array( 'Test', 'Test', 'selfmove' ), + array( 'Special:FooBar', 'Test', 'immobile-source-namespace' ), + array( 'Test', 'Special:FooBar', 'immobile-target-namespace' ), + array( 'MediaWiki:Common.js', 'Help:Some wikitext page', 'bad-target-model' ), + array( 'Page', 'File:Test.jpg', 'nonfile-cannot-move-to-file' ), + // for Title::validateFileMoveOperation + array( 'File:Test.jpg', 'Page', 'imagenocrossnamespace' ), + ); + } + + /** + * Auth-less test of Title::userCan + * + * @param array $whitelistRegexp + * @param string $source + * @param string $action + * @param array|string|bool $expected Required error + * + * @covers Title::checkReadPermissions + * @dataProvider dataWgWhitelistReadRegexp + */ + public function testWgWhitelistReadRegexp( $whitelistRegexp, $source, $action, $expected ) { + // $wgWhitelistReadRegexp must be an array. Since the provided test cases + // usually have only one regex, it is more concise to write the lonely regex + // as a string. Thus we cast to an array() to honor $wgWhitelistReadRegexp + // type requisite. + if ( is_string( $whitelistRegexp ) ) { + $whitelistRegexp = array( $whitelistRegexp ); + } + + $title = Title::newFromDBkey( $source ); + + global $wgGroupPermissions; + $oldPermissions = $wgGroupPermissions; + // Disallow all so we can ensure our regex works + $wgGroupPermissions = array(); + $wgGroupPermissions['*']['read'] = false; + + global $wgWhitelistRead; + $oldWhitelist = $wgWhitelistRead; + // Undo any LocalSettings explicite whitelists so they won't cause a + // failing test to succeed. Set it to some random non sense just + // to make sure we properly test Title::checkReadPermissions() + $wgWhitelistRead = array( 'some random non sense title' ); + + global $wgWhitelistReadRegexp; + $oldWhitelistRegexp = $wgWhitelistReadRegexp; + $wgWhitelistReadRegexp = $whitelistRegexp; + + // Just use $wgUser which in test is a user object for '127.0.0.1' + global $wgUser; + // Invalidate user rights cache to take in account $wgGroupPermissions + // change above. + $wgUser->clearInstanceCache(); + $errors = $title->userCan( $action, $wgUser ); + + // Restore globals + $wgGroupPermissions = $oldPermissions; + $wgWhitelistRead = $oldWhitelist; + $wgWhitelistReadRegexp = $oldWhitelistRegexp; + + if ( is_bool( $expected ) ) { + # Forge the assertion message depending on the assertion expectation + $allowableness = $expected + ? " should be allowed" + : " should NOT be allowed"; + $this->assertEquals( + $expected, + $errors, + "User action '$action' on [[$source]] $allowableness." + ); + } else { + $errors = $this->flattenErrorsArray( $errors ); + foreach ( (array)$expected as $error ) { + $this->assertContains( $error, $errors ); + } + } + } + + /** + * Provides test parameter values for testWgWhitelistReadRegexp() + */ + public function dataWgWhitelistReadRegexp() { + $ALLOWED = true; + $DISALLOWED = false; + + return array( + // Everything, if this doesn't work, we're really in trouble + array( '/.*/', 'Main_Page', 'read', $ALLOWED ), + array( '/.*/', 'Main_Page', 'edit', $DISALLOWED ), + + // We validate against the title name, not the db key + array( '/^Main_Page$/', 'Main_Page', 'read', $DISALLOWED ), + // Main page + array( '/^Main/', 'Main_Page', 'read', $ALLOWED ), + array( '/^Main.*/', 'Main_Page', 'read', $ALLOWED ), + // With spaces + array( '/Mic\sCheck/', 'Mic Check', 'read', $ALLOWED ), + // Unicode multibyte + // ...without unicode modifier + array( '/Unicode Test . Yes/', 'Unicode Test Ñ Yes', 'read', $DISALLOWED ), + // ...with unicode modifier + array( '/Unicode Test . Yes/u', 'Unicode Test Ñ Yes', 'read', $ALLOWED ), + // Case insensitive + array( '/MiC ChEcK/', 'mic check', 'read', $DISALLOWED ), + array( '/MiC ChEcK/i', 'mic check', 'read', $ALLOWED ), + + // From DefaultSettings.php: + array( "@^UsEr.*@i", 'User is banned', 'read', $ALLOWED ), + array( "@^UsEr.*@i", 'User:John Doe', 'read', $ALLOWED ), + + // With namespaces: + array( '/^Special:NewPages$/', 'Special:NewPages', 'read', $ALLOWED ), + array( null, 'Special:Newpages', 'read', $DISALLOWED ), + + ); + } + + public function flattenErrorsArray( $errors ) { + $result = array(); + foreach ( $errors as $error ) { + $result[] = $error[0]; + } + + return $result; + } + + /** + * @dataProvider provideGetPageViewLanguage + * @covers Title::getPageViewLanguage + */ + public function testGetPageViewLanguage( $expected, $titleText, $contLang, + $lang, $variant, $msg = '' + ) { + global $wgLanguageCode, $wgContLang, $wgLang, $wgDefaultLanguageVariant, $wgAllowUserJs; + + // Setup environnement for this test + $wgLanguageCode = $contLang; + $wgContLang = Language::factory( $contLang ); + $wgLang = Language::factory( $lang ); + $wgDefaultLanguageVariant = $variant; + $wgAllowUserJs = true; + + $title = Title::newFromText( $titleText ); + $this->assertInstanceOf( 'Title', $title, + "Test must be passed a valid title text, you gave '$titleText'" + ); + $this->assertEquals( $expected, + $title->getPageViewLanguage()->getCode(), + $msg + ); + } + + public static function provideGetPageViewLanguage() { + # Format: + # - expected + # - Title name + # - wgContLang (expected in most case) + # - wgLang (on some specific pages) + # - wgDefaultLanguageVariant + # - Optional message + return array( + array( 'fr', 'Help:I_need_somebody', 'fr', 'fr', false ), + array( 'es', 'Help:I_need_somebody', 'es', 'zh-tw', false ), + array( 'zh', 'Help:I_need_somebody', 'zh', 'zh-tw', false ), + + array( 'es', 'Help:I_need_somebody', 'es', 'zh-tw', 'zh-cn' ), + array( 'es', 'MediaWiki:About', 'es', 'zh-tw', 'zh-cn' ), + array( 'es', 'MediaWiki:About/', 'es', 'zh-tw', 'zh-cn' ), + array( 'de', 'MediaWiki:About/de', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.js', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.css', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Common.js', 'es', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Monobook.css', 'es', 'zh-tw', 'zh-cn' ), + + array( 'zh-cn', 'Help:I_need_somebody', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh', 'MediaWiki:About', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh', 'MediaWiki:About/', 'zh', 'zh-tw', 'zh-cn' ), + array( 'de', 'MediaWiki:About/de', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh-cn', 'MediaWiki:About/zh-cn', 'zh', 'zh-tw', 'zh-cn' ), + array( 'zh-tw', 'MediaWiki:About/zh-tw', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.js', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'MediaWiki:Common.css', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Common.js', 'zh', 'zh-tw', 'zh-cn' ), + array( 'en', 'User:JohnDoe/Monobook.css', 'zh', 'zh-tw', 'zh-cn' ), + + array( 'zh-tw', 'Special:NewPages', 'es', 'zh-tw', 'zh-cn' ), + array( 'zh-tw', 'Special:NewPages', 'zh', 'zh-tw', 'zh-cn' ), + + ); + } + + /** + * @dataProvider provideBaseTitleCases + * @covers Title::getBaseText + */ + public function testGetBaseText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getBaseText(), + $msg + ); + } + + public static function provideBaseTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'John Doe/subOne' ), + array( 'User:Foo/Bar/Baz', 'Foo/Bar' ), + ); + } + + /** + * @dataProvider provideRootTitleCases + * @covers Title::getRootText + */ + public function testGetRootText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getRootText(), + $msg + ); + } + + public static function provideRootTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'John Doe' ), + array( 'User:Foo/Bar/Baz', 'Foo' ), + ); + } + + /** + * @todo Handle $wgNamespacesWithSubpages cases + * @dataProvider provideSubpageTitleCases + * @covers Title::getSubpageText + */ + public function testGetSubpageText( $title, $expected, $msg = '' ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expected, + $title->getSubpageText(), + $msg + ); + } + + public static function provideSubpageTitleCases() { + return array( + # Title, expected base, optional message + array( 'User:John_Doe/subOne/subTwo', 'subTwo' ), + array( 'User:John_Doe/subOne', 'subOne' ), + ); + } + + public static function provideNewFromTitleValue() { + return array( + array( new TitleValue( NS_MAIN, 'Foo' ) ), + array( new TitleValue( NS_MAIN, 'Foo', 'bar' ) ), + array( new TitleValue( NS_USER, 'Hansi_Maier' ) ), + ); + } + + /** + * @dataProvider provideNewFromTitleValue + */ + public function testNewFromTitleValue( TitleValue $value ) { + $title = Title::newFromTitleValue( $value ); + + $dbkey = str_replace( ' ', '_', $value->getText() ); + $this->assertEquals( $dbkey, $title->getDBkey() ); + $this->assertEquals( $value->getNamespace(), $title->getNamespace() ); + $this->assertEquals( $value->getFragment(), $title->getFragment() ); + } + + public static function provideGetTitleValue() { + return array( + array( 'Foo' ), + array( 'Foo#bar' ), + array( 'User:Hansi_Maier' ), + ); + } + + /** + * @dataProvider provideGetTitleValue + */ + public function testGetTitleValue( $text ) { + $title = Title::newFromText( $text ); + $value = $title->getTitleValue(); + + $dbkey = str_replace( ' ', '_', $value->getText() ); + $this->assertEquals( $title->getDBkey(), $dbkey ); + $this->assertEquals( $title->getNamespace(), $value->getNamespace() ); + $this->assertEquals( $title->getFragment(), $value->getFragment() ); + } + + public static function provideGetFragment() { + return array( + array( 'Foo', '' ), + array( 'Foo#bar', 'bar' ), + array( 'Foo#bär', 'bär' ), + + // Inner whitespace is normalized + array( 'Foo#bar_bar', 'bar bar' ), + array( 'Foo#bar bar', 'bar bar' ), + array( 'Foo#bar bar', 'bar bar' ), + + // Leading whitespace is kept, trailing whitespace is trimmed. + // XXX: Is this really want we want? + array( 'Foo#_bar_bar_', ' bar bar' ), + array( 'Foo# bar bar ', ' bar bar' ), + ); + } + + /** + * @dataProvider provideGetFragment + * + * @param string $full + * @param string $fragment + */ + public function testGetFragment( $full, $fragment ) { + $title = Title::newFromText( $full ); + $this->assertEquals( $fragment, $title->getFragment() ); + } + + /** + * @covers Title::isAlwaysKnown + * @dataProvider provideIsAlwaysKnown + * @param string $page + * @param bool $isKnown + */ + public function testIsAlwaysKnown( $page, $isKnown ) { + $title = Title::newFromText( $page ); + $this->assertEquals( $isKnown, $title->isAlwaysKnown() ); + } + + public static function provideIsAlwaysKnown() { + return array( + array( 'Some nonexistent page', false ), + array( 'UTPage', false ), + array( '#test', true ), + array( 'Special:BlankPage', true ), + array( 'Special:SomeNonexistentSpecialPage', false ), + array( 'MediaWiki:Parentheses', true ), + array( 'MediaWiki:Some nonexistent message', false ), + ); + } + + /** + * @covers Title::isAlwaysKnown + */ + public function testIsAlwaysKnownOnInterwiki() { + $title = Title::makeTitle( NS_MAIN, 'Interwiki link', '', 'externalwiki' ); + $this->assertTrue( $title->isAlwaysKnown() ); + } +} diff --git a/tests/phpunit/includes/UserArrayFromResultTest.php b/tests/phpunit/includes/UserArrayFromResultTest.php new file mode 100644 index 00000000..62989faa --- /dev/null +++ b/tests/phpunit/includes/UserArrayFromResultTest.php @@ -0,0 +1,114 @@ +<?php + +/** + * @author Adam Shorland + * @covers UserArrayFromResult + */ +class UserArrayFromResultTest extends MediaWikiTestCase { + + private function getMockResultWrapper( $row = null, $numRows = 1 ) { + $resultWrapper = $this->getMockBuilder( 'ResultWrapper' ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithUsername( $username = 'fooUser' ) { + $row = new stdClass(); + $row->user_name = $username; + return $row; + } + + private function getUserArrayFromResult( $resultWrapper ) { + return new UserArrayFromResult( $resultWrapper ); + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getUserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers UserArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $username = 'addshore'; + $row = $this->getRowWithUsername( $username ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = $this->getUserArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( 'User', $object->current ); + $this->assertEquals( $username, $object->current->mName ); + } + + public static function provideNumberOfRows() { + return array( + array( 0 ), + array( 1 ), + array( 122 ), + ); + } + + /** + * @dataProvider provideNumberOfRows + * @covers UserArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithUsername(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers UserArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $username = 'addshore'; + $userRow = $this->getRowWithUsername( $username ); + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $userRow ) ); + $this->assertInstanceOf( 'User', $object->current() ); + $this->assertEquals( $username, $object->current()->mName ); + } + + public function provideTestValid() { + return array( + array( $this->getRowWithUsername(), true ), + array( false, false ), + ); + } + + /** + * @dataProvider provideTestValid + * @covers UserArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = $this->getUserArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + //@todo unit test for key() + //@todo unit test for next() + //@todo unit test for rewind() +} diff --git a/tests/phpunit/includes/UserTest.php b/tests/phpunit/includes/UserTest.php new file mode 100644 index 00000000..af95a721 --- /dev/null +++ b/tests/phpunit/includes/UserTest.php @@ -0,0 +1,369 @@ +<?php + +define( 'NS_UNITTEST', 5600 ); +define( 'NS_UNITTEST_TALK', 5601 ); + +/** + * @group Database + */ +class UserTest extends MediaWikiTestCase { + /** + * @var User + */ + protected $user; + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgGroupPermissions' => array(), + 'wgRevokePermissions' => array(), + ) ); + + $this->setUpPermissionGlobals(); + + $this->user = new User; + $this->user->addGroup( 'unittesters' ); + } + + private function setUpPermissionGlobals() { + global $wgGroupPermissions, $wgRevokePermissions; + + # Data for regular $wgGroupPermissions test + $wgGroupPermissions['unittesters'] = array( + 'test' => true, + 'runtest' => true, + 'writetest' => false, + 'nukeworld' => false, + ); + $wgGroupPermissions['testwriters'] = array( + 'test' => true, + 'writetest' => true, + 'modifytest' => true, + ); + + # Data for regular $wgRevokePermissions test + $wgRevokePermissions['formertesters'] = array( + 'runtest' => true, + ); + + # For the options test + $wgGroupPermissions['*'] = array( + 'editmyoptions' => true, + ); + } + + /** + * @covers User::getGroupPermissions + */ + public function testGroupPermissions() { + $rights = User::getGroupPermissions( array( 'unittesters' ) ); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + + $rights = User::getGroupPermissions( array( 'unittesters', 'testwriters' ) ); + $this->assertContains( 'runtest', $rights ); + $this->assertContains( 'writetest', $rights ); + $this->assertContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers User::getGroupPermissions + */ + public function testRevokePermissions() { + $rights = User::getGroupPermissions( array( 'unittesters', 'formertesters' ) ); + $this->assertNotContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @covers User::getRights + */ + public function testUserPermissions() { + $rights = $this->user->getRights(); + $this->assertContains( 'runtest', $rights ); + $this->assertNotContains( 'writetest', $rights ); + $this->assertNotContains( 'modifytest', $rights ); + $this->assertNotContains( 'nukeworld', $rights ); + } + + /** + * @dataProvider provideGetGroupsWithPermission + * @covers User::getGroupsWithPermission + */ + public function testGetGroupsWithPermission( $expected, $right ) { + $result = User::getGroupsWithPermission( $right ); + sort( $result ); + sort( $expected ); + + $this->assertEquals( $expected, $result, "Groups with permission $right" ); + } + + public static function provideGetGroupsWithPermission() { + return array( + array( + array( 'unittesters', 'testwriters' ), + 'test' + ), + array( + array( 'unittesters' ), + 'runtest' + ), + array( + array( 'testwriters' ), + 'writetest' + ), + array( + array( 'testwriters' ), + 'modifytest' + ), + ); + } + + /** + * @dataProvider provideIPs + * @covers User::isIP + */ + public function testIsIP( $value, $result, $message ) { + $this->assertEquals( $this->user->isIP( $value ), $result, $message ); + } + + public static function provideIPs() { + return array( + array( '', false, 'Empty string' ), + array( ' ', false, 'Blank space' ), + array( '10.0.0.0', true, 'IPv4 private 10/8' ), + array( '10.255.255.255', true, 'IPv4 private 10/8' ), + array( '192.168.1.1', true, 'IPv4 private 192.168/16' ), + array( '203.0.113.0', true, 'IPv4 example' ), + array( '2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff', true, 'IPv6 example' ), + // Not valid IPs but classified as such by MediaWiki for negated asserting + // of whether this might be the identifier of a logged-out user or whether + // to allow usernames like it. + array( '300.300.300.300', true, 'Looks too much like an IPv4 address' ), + array( '203.0.113.xxx', true, 'Assigned by UseMod to cloaked logged-out users' ), + ); + } + + /** + * @dataProvider provideUserNames + * @covers User::isValidUserName + */ + public function testIsValidUserName( $username, $result, $message ) { + $this->assertEquals( $this->user->isValidUserName( $username ), $result, $message ); + } + + public static function provideUserNames() { + return array( + array( '', false, 'Empty string' ), + array( ' ', false, 'Blank space' ), + array( 'abcd', false, 'Starts with small letter' ), + array( 'Ab/cd', false, 'Contains slash' ), + array( 'Ab cd', true, 'Whitespace' ), + array( '192.168.1.1', false, 'IP' ), + array( 'User:Abcd', false, 'Reserved Namespace' ), + array( '12abcd232', true, 'Starts with Numbers' ), + array( '?abcd', true, 'Start with ? mark' ), + array( '#abcd', false, 'Start with #' ), + array( 'Abcdകഖഗഘ', true, ' Mixed scripts' ), + array( 'ജോസ്തോമസ്', false, 'ZWNJ- Format control character' ), + array( 'Ab cd', false, ' Ideographic space' ), + array( '300.300.300.300', false, 'Looks too much like an IPv4 address' ), + array( '302.113.311.900', false, 'Looks too much like an IPv4 address' ), + array( '203.0.113.xxx', false, 'Reserved for usage by UseMod for cloaked logged-out users' ), + ); + } + + /** + * Test, if for all rights a right- message exist, + * which is used on Special:ListGroupRights as help text + * Extensions and core + */ + public function testAllRightsWithMessage() { + // Getting all user rights, for core: User::$mCoreRights, for extensions: $wgAvailableRights + $allRights = User::getAllRights(); + $allMessageKeys = Language::getMessageKeysFor( 'en' ); + + $rightsWithMessage = array(); + foreach ( $allMessageKeys as $message ) { + // === 0: must be at beginning of string (position 0) + if ( strpos( $message, 'right-' ) === 0 ) { + $rightsWithMessage[] = substr( $message, strlen( 'right-' ) ); + } + } + + sort( $allRights ); + sort( $rightsWithMessage ); + + $this->assertEquals( + $allRights, + $rightsWithMessage, + 'Each user rights (core/extensions) has a corresponding right- message.' + ); + } + + /** + * Test User::editCount + * @group medium + * @covers User::getEditCount + */ + public function testEditCount() { + $user = User::newFromName( 'UnitTestUser' ); + $user->loadDefaults(); + $user->addToDatabase(); + + // let the user have a few (3) edits + $page = WikiPage::factory( Title::newFromText( 'Help:UserTest_EditCount' ) ); + for ( $i = 0; $i < 3; $i++ ) { + $page->doEdit( (string)$i, 'test', 0, false, $user ); + } + + $user->clearInstanceCache(); + $this->assertEquals( + 3, + $user->getEditCount(), + 'After three edits, the user edit count should be 3' + ); + + // increase the edit count and clear the cache + $user->incEditCount(); + + $user->clearInstanceCache(); + $this->assertEquals( + 4, + $user->getEditCount(), + 'After increasing the edit count manually, the user edit count should be 4' + ); + } + + /** + * Test changing user options. + * @covers User::setOption + * @covers User::getOption + */ + public function testOptions() { + $user = User::newFromName( 'UnitTestUser' ); + $user->addToDatabase(); + + $user->setOption( 'someoption', 'test' ); + $user->setOption( 'cols', 200 ); + $user->saveSettings(); + + $user = User::newFromName( 'UnitTestUser' ); + $this->assertEquals( 'test', $user->getOption( 'someoption' ) ); + $this->assertEquals( 200, $user->getOption( 'cols' ) ); + } + + /** + * Bug 37963 + * Make sure defaults are loaded when setOption is called. + * @covers User::loadOptions + */ + public function testAnonOptions() { + global $wgDefaultUserOptions; + $this->user->setOption( 'someoption', 'test' ); + $this->assertEquals( $wgDefaultUserOptions['cols'], $this->user->getOption( 'cols' ) ); + $this->assertEquals( 'test', $this->user->getOption( 'someoption' ) ); + } + + /** + * Test password expiration. + * @covers User::getPasswordExpired() + */ + public function testPasswordExpire() { + global $wgPasswordExpireGrace; + $wgTemp = $wgPasswordExpireGrace; + $wgPasswordExpireGrace = 3600 * 24 * 7; // 7 days + + $user = User::newFromName( 'UnitTestUser' ); + $user->loadDefaults(); + $this->assertEquals( false, $user->getPasswordExpired() ); + + $ts = time() - ( 3600 * 24 * 1 ); // 1 day ago + $user->expirePassword( $ts ); + $this->assertEquals( 'soft', $user->getPasswordExpired() ); + + $ts = time() - ( 3600 * 24 * 10 ); // 10 days ago + $user->expirePassword( $ts ); + $this->assertEquals( 'hard', $user->getPasswordExpired() ); + + $wgPasswordExpireGrace = $wgTemp; + } + + /** + * Test password validity checks. There are 3 checks in core, + * - ensure the password meets the minimal length + * - ensure the password is not the same as the username + * - ensure the username/password combo isn't forbidden + * @covers User::checkPasswordValidity() + * @covers User::getPasswordValidity() + * @covers User::isValidPassword() + */ + public function testCheckPasswordValidity() { + $this->setMwGlobals( array( + 'wgMinimalPasswordLength' => 6, + 'wgMaximalPasswordLength' => 30, + ) ); + $user = User::newFromName( 'Useruser' ); + // Sanity + $this->assertTrue( $user->isValidPassword( 'Password1234' ) ); + + // Minimum length + $this->assertFalse( $user->isValidPassword( 'a' ) ); + $this->assertFalse( $user->checkPasswordValidity( 'a' )->isGood() ); + $this->assertTrue( $user->checkPasswordValidity( 'a' )->isOK() ); + $this->assertEquals( 'passwordtooshort', $user->getPasswordValidity( 'a' ) ); + + // Maximum length + $longPass = str_repeat( 'a', 31 ); + $this->assertFalse( $user->isValidPassword( $longPass ) ); + $this->assertFalse( $user->checkPasswordValidity( $longPass )->isGood() ); + $this->assertFalse( $user->checkPasswordValidity( $longPass )->isOK() ); + $this->assertEquals( 'passwordtoolong', $user->getPasswordValidity( $longPass ) ); + + // Matches username + $this->assertFalse( $user->checkPasswordValidity( 'Useruser' )->isGood() ); + $this->assertTrue( $user->checkPasswordValidity( 'Useruser' )->isOK() ); + $this->assertEquals( 'password-name-match', $user->getPasswordValidity( 'Useruser' ) ); + + // On the forbidden list + $this->assertFalse( $user->checkPasswordValidity( 'Passpass' )->isGood() ); + $this->assertEquals( 'password-login-forbidden', $user->getPasswordValidity( 'Passpass' ) ); + } + + /** + * @covers User::getCanonicalName() + * @dataProvider provideGetCanonicalName + */ + public function testGetCanonicalName( $name, $expectedArray, $msg ) { + foreach ( $expectedArray as $validate => $expected ) { + $this->assertEquals( + User::getCanonicalName( $name, $validate === 'false' ? false : $validate ), + $expected, + $msg . ' (' . $validate . ')' + ); + } + } + + public static function provideGetCanonicalName() { + return array( + array( ' trailing space ', array( 'creatable' => 'Trailing space' ), 'Trailing spaces' ), + // @todo FIXME: Maybe the createable name should be 'Talk:Username' or false to reject? + array( 'Talk:Username', array( 'creatable' => 'Username', 'usable' => 'Username', + 'valid' => 'Username', 'false' => 'Talk:Username' ), 'Namespace prefix' ), + array( ' name with # hash', array( 'creatable' => false, 'usable' => false ), 'With hash' ), + array( 'Multi spaces', array( 'creatable' => 'Multi spaces', + 'usable' => 'Multi spaces' ), 'Multi spaces' ), + array( 'lowercase', array( 'creatable' => 'Lowercase' ), 'Lowercase' ), + array( 'in[]valid', array( 'creatable' => false, 'usable' => false, 'valid' => false, + 'false' => 'In[]valid' ), 'Invalid' ), + array( 'with / slash', array( 'creatable' => false, 'usable' => false, 'valid' => false, + 'false' => 'With / slash' ), 'With slash' ), + ); + } +} diff --git a/tests/phpunit/includes/WebRequestTest.php b/tests/phpunit/includes/WebRequestTest.php new file mode 100644 index 00000000..12d7d2a3 --- /dev/null +++ b/tests/phpunit/includes/WebRequestTest.php @@ -0,0 +1,358 @@ +<?php + +/** + * @group WebRequest + */ +class WebRequestTest extends MediaWikiTestCase { + protected $oldServer; + + protected function setUp() { + parent::setUp(); + + $this->oldServer = $_SERVER; + IP::clearCaches(); + } + + protected function tearDown() { + $_SERVER = $this->oldServer; + IP::clearCaches(); + + parent::tearDown(); + } + + /** + * @dataProvider provideDetectServer + * @covers WebRequest::detectServer + */ + public function testDetectServer( $expected, $input, $description ) { + $_SERVER = $input; + $result = WebRequest::detectServer(); + $this->assertEquals( $expected, $result, $description ); + } + + public static function provideDetectServer() { + return array( + array( + 'http://x', + array( + 'HTTP_HOST' => 'x' + ), + 'Host header' + ), + array( + 'https://x', + array( + 'HTTP_HOST' => 'x', + 'HTTPS' => 'on', + ), + 'Host header with secure' + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'SERVER_PORT' => 80, + ), + 'Default SERVER_PORT', + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'HTTPS' => 'off', + ), + 'Secure off' + ), + array( + 'http://y', + array( + 'SERVER_NAME' => 'y', + ), + 'Server name' + ), + array( + 'http://x', + array( + 'HTTP_HOST' => 'x', + 'SERVER_NAME' => 'y', + ), + 'Host server name precedence' + ), + array( + 'http://[::1]:81', + array( + 'HTTP_HOST' => '[::1]', + 'SERVER_NAME' => '::1', + 'SERVER_PORT' => '81', + ), + 'Apache bug 26005' + ), + array( + 'http://localhost', + array( + 'SERVER_NAME' => '[2001' + ), + 'Kind of like lighttpd per commit message in MW r83847', + ), + array( + 'http://[2a01:e35:2eb4:1::2]:777', + array( + 'SERVER_NAME' => '[2a01:e35:2eb4:1::2]:777' + ), + 'Possible lighttpd environment per bug 14977 comment 13', + ), + ); + } + + /** + * @dataProvider provideGetIP + * @covers WebRequest::getIP + */ + public function testGetIP( $expected, $input, $squid, $xffList, $private, $description ) { + $_SERVER = $input; + $this->setMwGlobals( array( + 'wgSquidServersNoPurge' => $squid, + 'wgUsePrivateIPs' => $private, + 'wgHooks' => array( + 'IsTrustedProxy' => array( + function ( &$ip, &$trusted ) use ( $xffList ) { + $trusted = $trusted || in_array( $ip, $xffList ); + return true; + } + ) + ) + ) ); + + $request = new WebRequest(); + $result = $request->getIP(); + $this->assertEquals( $expected, $result, $description ); + } + + public static function provideGetIP() { + return array( + array( + '127.0.0.1', + array( + 'REMOTE_ADDR' => '127.0.0.1' + ), + array(), + array(), + false, + 'Simple IPv4' + ), + array( + '::1', + array( + 'REMOTE_ADDR' => '::1' + ), + array(), + array(), + false, + 'Simple IPv6' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777', + ), + array( 'ABCD:1:2:3:4:555:6666:7777' ), + array(), + false, + 'IPv6 normalisation' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array(), + false, + 'With X-Forwaded-For' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array(), + false, + 'With X-Forwaded-For and disallowed server' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1' ), + array(), + false, + 'With multiple X-Forwaded-For and only one allowed server' + ), + array( + '10.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array(), + false, + 'With X-Forwaded-For and private IP (from cache proxy)' + ), + array( + '10.0.0.4', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2', '10.0.0.3' ), + array(), + true, + 'With X-Forwaded-For and private IP (allowed)' + ), + array( + '10.0.0.4', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array( '10.0.0.3' ), + true, + 'With X-Forwaded-For and private IP (allowed)' + ), + array( + '10.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.4, 10.0.0.3, 12.0.0.2' + ), + array( '12.0.0.1', '12.0.0.2' ), + array( '10.0.0.3' ), + false, + 'With X-Forwaded-For and private IP (disallowed)' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.1', '12.0.0.2' ), + false, + 'With X-Forwaded-For' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.1' ), + false, + 'With multiple X-Forwaded-For and only one allowed server' + ), + array( + '12.0.0.2', + array( + 'REMOTE_ADDR' => '12.0.0.2', + 'HTTP_X_FORWARDED_FOR' => '10.0.0.3, 12.0.0.2' + ), + array(), + array( '12.0.0.2' ), + false, + 'With X-Forwaded-For and private IP and hook (disallowed)' + ), + array( + '12.0.0.1', + array( + 'REMOTE_ADDR' => 'abcd:0001:002:03:4:555:6666:7777', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.1, abcd:0001:002:03:4:555:6666:7777', + ), + array( 'ABCD:1:2:3::/64' ), + array(), + false, + 'IPv6 CIDR' + ), + array( + '12.0.0.3', + array( + 'REMOTE_ADDR' => '12.0.0.1', + 'HTTP_X_FORWARDED_FOR' => '12.0.0.3, 12.0.0.2' + ), + array( '12.0.0.0/24' ), + array(), + false, + 'IPv4 CIDR' + ), + ); + } + + /** + * @expectedException MWException + * @covers WebRequest::getIP + */ + public function testGetIpLackOfRemoteAddrThrowAnException() { + // ensure that local install state doesn't interfere with test + $this->setMwGlobals( array( + 'wgSquidServersNoPurge' => array(), + 'wgSquidServers' => array(), + 'wgUsePrivateIPs' => false, + 'wgHooks' => array(), + ) ); + + $request = new WebRequest(); + # Next call throw an exception about lacking an IP + $request->getIP(); + } + + public static function provideLanguageData() { + return array( + array( '', array(), 'Empty Accept-Language header' ), + array( 'en', array( 'en' => 1 ), 'One language' ), + array( 'en, ar', array( 'en' => 1, 'ar' => 1 ), 'Two languages listed in appearance order.' ), + array( + 'zh-cn,zh-tw', + array( 'zh-cn' => 1, 'zh-tw' => 1 ), + 'Two equally prefered languages, listed in appearance order per rfc3282. Checks c9119' + ), + array( + 'es, en; q=0.5', + array( 'es' => 1, 'en' => '0.5' ), + 'Spanish as first language and English and second' + ), + array( 'en; q=0.5, es', array( 'es' => 1, 'en' => '0.5' ), 'Less prefered language first' ), + array( 'fr, en; q=0.5, es', array( 'fr' => 1, 'es' => 1, 'en' => '0.5' ), 'Three languages' ), + array( 'en; q=0.5, es', array( 'es' => 1, 'en' => '0.5' ), 'Two languages' ), + array( 'en, zh;q=0', array( 'en' => 1 ), "It's Chinese to me" ), + array( + 'es; q=1, pt;q=0.7, it; q=0.6, de; q=0.1, ru;q=0', + array( 'es' => '1', 'pt' => '0.7', 'it' => '0.6', 'de' => '0.1' ), + 'Preference for Romance languages' + ), + array( + 'en-gb, en-us; q=1', + array( 'en-gb' => 1, 'en-us' => '1' ), + 'Two equally prefered English variants' + ), + ); + } + + /** + * @dataProvider provideLanguageData + * @covers WebRequest::getAcceptLang + */ + public function testAcceptLang( $acceptLanguageHeader, $expectedLanguages, $description ) { + $_SERVER = array( 'HTTP_ACCEPT_LANGUAGE' => $acceptLanguageHeader ); + $request = new WebRequest(); + $this->assertSame( $request->getAcceptLang(), $expectedLanguages, $description ); + } +} diff --git a/tests/phpunit/includes/WikiPageTest.php b/tests/phpunit/includes/WikiPageTest.php new file mode 100644 index 00000000..7f7945b8 --- /dev/null +++ b/tests/phpunit/includes/WikiPageTest.php @@ -0,0 +1,1301 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- important, causes temporary tables to be used instead of the real database + * @group medium + **/ +class WikiPageTest extends MediaWikiLangTestCase { + + protected $pages_to_delete; + + function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed = array_merge( + $this->tablesUsed, + array( 'page', + 'revision', + 'text', + + 'recentchanges', + 'logging', + + 'page_props', + 'pagelinks', + 'categorylinks', + 'langlinks', + 'externallinks', + 'imagelinks', + 'templatelinks', + 'iwlinks' ) ); + } + + protected function setUp() { + parent::setUp(); + $this->pages_to_delete = array(); + + LinkCache::singleton()->clear(); # avoid cached redirect status, etc + } + + protected function tearDown() { + foreach ( $this->pages_to_delete as $p ) { + /* @var $p WikiPage */ + + try { + if ( $p->exists() ) { + $p->doDeleteArticle( "testing done." ); + } + } catch ( MWException $ex ) { + // fail silently + } + } + parent::tearDown(); + } + + /** + * @param Title $title + * @param string $model + * @return WikiPage + */ + protected function newPage( $title, $model = null ) { + if ( is_string( $title ) ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + } + + $p = new WikiPage( $title ); + + $this->pages_to_delete[] = $p; + + return $p; + } + + /** + * @param string|Title|WikiPage $page + * @param string $text + * @param int $model + * + * @return WikiPage + */ + protected function createPage( $page, $text, $model = null ) { + if ( is_string( $page ) || $page instanceof Title ) { + $page = $this->newPage( $page, $model ); + } + + $content = ContentHandler::makeContent( $text, $page->getTitle(), $model ); + $page->doEditContent( $content, "testing", EDIT_NEW ); + + return $page; + } + + /** + * @covers WikiPage::doEditContent + */ + public function testDoEditContent() { + $page = $this->newPage( "WikiPageTest_testDoEditContent" ); + $title = $page->getTitle(); + + $content = ContentHandler::makeContent( + "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.", + $title, + CONTENT_MODEL_WIKITEXT + ); + + $page->doEditContent( $content, "[[testing]] 1" ); + + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); + $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); + $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); + + $id = $page->getId(); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $content = ContentHandler::makeContent( + "At vero eos et accusam et justo duo [[dolores]] et ea rebum. " + . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.", + $title, + CONTENT_MODEL_WIKITEXT + ); + + $page->doEditContent( $content, "testing 2" ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getContent(); + $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); + } + + /** + * @covers WikiPage::doEdit + */ + public function testDoEdit() { + $this->hideDeprecated( "WikiPage::doEdit" ); + $this->hideDeprecated( "WikiPage::getText" ); + $this->hideDeprecated( "Revision::getText" ); + + //NOTE: assume help namespace will default to wikitext + $title = Title::newFromText( "Help:WikiPageTest_testDoEdit" ); + + $page = $this->newPage( $title ); + + $text = "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat."; + + $page->doEdit( $text, "[[testing]] 1" ); + + $this->assertTrue( $title->getArticleID() > 0, "Title object should have new page id" ); + $this->assertTrue( $page->getId() > 0, "WikiPage should have new page id" ); + $this->assertTrue( $title->exists(), "Title object should indicate that the page now exists" ); + $this->assertTrue( $page->exists(), "WikiPage object should indicate that the page now exists" ); + + $id = $page->getId(); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 1, $n, 'pagelinks should contain one link from the page' ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getText(); + $this->assertEquals( $text, $retrieved, 'retrieved text doesn\'t equal original' ); + + # ------------------------ + $text = "At vero eos et accusam et justo duo [[dolores]] et ea rebum. " + . "Stet clita kasd [[gubergren]], no sea takimata sanctus est."; + + $page->doEdit( $text, "testing 2" ); + + # ------------------------ + $page = new WikiPage( $title ); + + $retrieved = $page->getText(); + $this->assertEquals( $text, $retrieved, 'retrieved text doesn\'t equal original' ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 2, $n, 'pagelinks should contain two links from the page' ); + } + + /** + * @covers WikiPage::doQuickEdit + */ + public function testDoQuickEdit() { + global $wgUser; + + $this->hideDeprecated( "WikiPage::doQuickEdit" ); + + //NOTE: assume help namespace will default to wikitext + $page = $this->createPage( "Help:WikiPageTest_testDoQuickEdit", "original text" ); + + $text = "quick text"; + $page->doQuickEdit( $text, $wgUser, "testing q" ); + + # --------------------- + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $text, $page->getText() ); + } + + /** + * @covers WikiPage::doQuickEditContent + */ + public function testDoQuickEditContent() { + global $wgUser; + + $page = $this->createPage( + "WikiPageTest_testDoQuickEditContent", + "original text", + CONTENT_MODEL_WIKITEXT + ); + + $content = ContentHandler::makeContent( + "quick text", + $page->getTitle(), + CONTENT_MODEL_WIKITEXT + ); + $page->doQuickEditContent( $content, $wgUser, "testing q" ); + + # --------------------- + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $content->equals( $page->getContent() ) ); + } + + /** + * @covers WikiPage::doDeleteArticle + */ + public function testDoDeleteArticle() { + $page = $this->createPage( + "WikiPageTest_testDoDeleteArticle", + "[[original text]] foo", + CONTENT_MODEL_WIKITEXT + ); + $id = $page->getId(); + + $page->doDeleteArticle( "testing deletion" ); + + $this->assertFalse( + $page->getTitle()->getArticleID() > 0, + "Title object should now have page id 0" + ); + $this->assertFalse( $page->getId() > 0, "WikiPage should now have page id 0" ); + $this->assertFalse( + $page->exists(), + "WikiPage::exists should return false after page was deleted" + ); + $this->assertNull( + $page->getContent(), + "WikiPage::getContent should return null after page was deleted" + ); + $this->assertFalse( + $page->getText(), + "WikiPage::getText should return false after page was deleted" + ); + + $t = Title::newFromText( $page->getTitle()->getPrefixedText() ); + $this->assertFalse( + $t->exists(), + "Title::exists should return false after page was deleted" + ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' ); + } + + /** + * @covers WikiPage::doDeleteUpdates + */ + public function testDoDeleteUpdates() { + $page = $this->createPage( + "WikiPageTest_testDoDeleteArticle", + "[[original text]] foo", + CONTENT_MODEL_WIKITEXT + ); + $id = $page->getId(); + + $page->doDeleteUpdates( $id ); + + # ------------------------ + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'pagelinks', '*', array( 'pl_from' => $id ) ); + $n = $res->numRows(); + $res->free(); + + $this->assertEquals( 0, $n, 'pagelinks should contain no more links from the page' ); + } + + /** + * @covers WikiPage::getRevision + */ + public function testGetRevision() { + $page = $this->newPage( "WikiPageTest_testGetRevision" ); + + $rev = $page->getRevision(); + $this->assertNull( $rev ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $rev = $page->getRevision(); + + $this->assertEquals( $page->getLatest(), $rev->getId() ); + $this->assertEquals( "some text", $rev->getContent()->getNativeData() ); + } + + /** + * @covers WikiPage::getContent + */ + public function testGetContent() { + $page = $this->newPage( "WikiPageTest_testGetContent" ); + + $content = $page->getContent(); + $this->assertNull( $content ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $content = $page->getContent(); + $this->assertEquals( "some text", $content->getNativeData() ); + } + + /** + * @covers WikiPage::getText + */ + public function testGetText() { + $this->hideDeprecated( "WikiPage::getText" ); + + $page = $this->newPage( "WikiPageTest_testGetText" ); + + $text = $page->getText(); + $this->assertFalse( $text ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $text = $page->getText(); + $this->assertEquals( "some text", $text ); + } + + /** + * @covers WikiPage::getRawText + */ + public function testGetRawText() { + $this->hideDeprecated( "WikiPage::getRawText" ); + + $page = $this->newPage( "WikiPageTest_testGetRawText" ); + + $text = $page->getRawText(); + $this->assertFalse( $text ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + + $text = $page->getRawText(); + $this->assertEquals( "some text", $text ); + } + + /** + * @covers WikiPage::getContentModel + */ + public function testGetContentModel() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $page = $this->createPage( + "WikiPageTest_testGetContentModel", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $page->getContentModel() ); + } + + /** + * @covers WikiPage::getContentHandler + */ + public function testGetContentHandler() { + global $wgContentHandlerUseDB; + + if ( !$wgContentHandlerUseDB ) { + $this->markTestSkipped( '$wgContentHandlerUseDB is disabled' ); + } + + $page = $this->createPage( + "WikiPageTest_testGetContentHandler", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'JavaScriptContentHandler', get_class( $page->getContentHandler() ) ); + } + + /** + * @covers WikiPage::exists + */ + public function testExists() { + $page = $this->newPage( "WikiPageTest_testExists" ); + $this->assertFalse( $page->exists() ); + + # ----------------- + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + $this->assertTrue( $page->exists() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $page->exists() ); + + # ----------------- + $page->doDeleteArticle( "done testing" ); + $this->assertFalse( $page->exists() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertFalse( $page->exists() ); + } + + public static function provideHasViewableContent() { + return array( + array( 'WikiPageTest_testHasViewableContent', false, true ), + array( 'Special:WikiPageTest_testHasViewableContent', false ), + array( 'MediaWiki:WikiPageTest_testHasViewableContent', false ), + array( 'Special:Userlogin', true ), + array( 'MediaWiki:help', true ), + ); + } + + /** + * @dataProvider provideHasViewableContent + * @covers WikiPage::hasViewableContent + */ + public function testHasViewableContent( $title, $viewable, $create = false ) { + $page = $this->newPage( $title ); + $this->assertEquals( $viewable, $page->hasViewableContent() ); + + if ( $create ) { + $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT ); + $this->assertTrue( $page->hasViewableContent() ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertTrue( $page->hasViewableContent() ); + } + } + + public static function provideGetRedirectTarget() { + return array( + array( 'WikiPageTest_testGetRedirectTarget_1', CONTENT_MODEL_WIKITEXT, "hello world", null ), + array( + 'WikiPageTest_testGetRedirectTarget_2', + CONTENT_MODEL_WIKITEXT, + "#REDIRECT [[hello world]]", + "Hello world" + ), + ); + } + + /** + * @dataProvider provideGetRedirectTarget + * @covers WikiPage::getRedirectTarget + */ + public function testGetRedirectTarget( $title, $model, $text, $target ) { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $page = $this->createPage( $title, $text, $model ); + + # sanity check, because this test seems to fail for no reason for some people. + $c = $page->getContent(); + $this->assertEquals( 'WikitextContent', get_class( $c ) ); + + # now, test the actual redirect + $t = $page->getRedirectTarget(); + $this->assertEquals( $target, is_null( $t ) ? null : $t->getPrefixedText() ); + } + + /** + * @dataProvider provideGetRedirectTarget + * @covers WikiPage::isRedirect + */ + public function testIsRedirect( $title, $model, $text, $target ) { + $page = $this->createPage( $title, $text, $model ); + $this->assertEquals( !is_null( $target ), $page->isRedirect() ); + } + + public static function provideIsCountable() { + return array( + + // any + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '', + 'any', + true + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'any', + true + ), + + // comma + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'comma', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo, bar', + 'comma', + true + ), + + // link + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'link', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo [[bar]]', + 'link', + true + ), + + // redirects + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'any', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'comma', + false + ), + array( 'WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + '#REDIRECT [[bar]]', + 'link', + false + ), + + // not a content namespace + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo', + 'any', + false + ), + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo, bar', + 'comma', + false + ), + array( 'Talk:WikiPageTest_testIsCountable', + CONTENT_MODEL_WIKITEXT, + 'Foo [[bar]]', + 'link', + false + ), + + // not a content namespace, different model + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo', + 'any', + false + ), + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo, bar', + 'comma', + false + ), + array( 'MediaWiki:WikiPageTest_testIsCountable.js', + null, + 'Foo [[bar]]', + 'link', + false + ), + ); + } + + /** + * @dataProvider provideIsCountable + * @covers WikiPage::isCountable + */ + public function testIsCountable( $title, $model, $text, $mode, $expected ) { + global $wgContentHandlerUseDB; + + $this->setMwGlobals( 'wgArticleCountMethod', $mode ); + + $title = Title::newFromText( $title ); + + if ( !$wgContentHandlerUseDB + && $model + && ContentHandler::getDefaultModelFor( $title ) != $model + ) { + $this->markTestSkipped( "Can not use non-default content model $model for " + . $title->getPrefixedDBkey() . " with \$wgContentHandlerUseDB disabled." ); + } + + $page = $this->createPage( $title, $text, $model ); + + $editInfo = $page->prepareContentForEdit( $page->getContent() ); + + $v = $page->isCountable(); + $w = $page->isCountable( $editInfo ); + + $this->assertEquals( + $expected, + $v, + "isCountable( null ) returned unexpected value " . var_export( $v, true ) + . " instead of " . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + + $this->assertEquals( + $expected, + $w, + "isCountable( \$editInfo ) returned unexpected value " . var_export( $v, true ) + . " instead of " . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + } + + public static function provideGetParserOutput() { + return array( + array( CONTENT_MODEL_WIKITEXT, "hello ''world''\n", "<p>hello <i>world</i></p>" ), + // @todo more...? + ); + } + + /** + * @dataProvider provideGetParserOutput + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput( $model, $text, $expectedHtml ) { + $page = $this->createPage( 'WikiPageTest_testGetParserOutput', $text, $model ); + + $opt = $page->makeParserOptions( 'canonical' ); + $po = $page->getParserOutput( $opt ); + $text = $po->getText(); + + $text = trim( preg_replace( '/<!--.*?-->/sm', '', $text ) ); # strip injected comments + $text = preg_replace( '!\s*(</p>)!sm', '\1', $text ); # don't let tidy confuse us + + $this->assertEquals( $expectedHtml, $text ); + + return $po; + } + + /** + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput_nonexisting() { + static $count = 0; + $count++; + + $page = new WikiPage( new Title( "WikiPageTest_testGetParserOutput_nonexisting_$count" ) ); + + $opt = new ParserOptions(); + $po = $page->getParserOutput( $opt ); + + $this->assertFalse( $po, "getParserOutput() shall return false for non-existing pages." ); + } + + /** + * @covers WikiPage::getParserOutput + */ + public function testGetParserOutput_badrev() { + $page = $this->createPage( 'WikiPageTest_testGetParserOutput', "dummy", CONTENT_MODEL_WIKITEXT ); + + $opt = new ParserOptions(); + $po = $page->getParserOutput( $opt, $page->getLatest() + 1234 ); + + // @todo would be neat to also test deleted revision + + $this->assertFalse( $po, "getParserOutput() shall return false for non-existing revisions." ); + } + + public static $sections = + + "Intro + +== stuff == +hello world + +== test == +just a test + +== foo == +more stuff +"; + + public function dataReplaceSection() { + //NOTE: assume the Help namespace to contain wikitext + return array( + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "0", + "No more", + null, + trim( preg_replace( '/^Intro/sm', 'No more', WikiPageTest::$sections ) ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "", + "No more", + null, + "No more" + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + trim( preg_replace( '/^== test ==.*== foo ==/sm', + "== TEST ==\nmore fun\n\n== foo ==", + WikiPageTest::$sections ) ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "8", + "No more", + null, + trim( WikiPageTest::$sections ) + ), + array( 'Help:WikiPageTest_testReplaceSection', + CONTENT_MODEL_WIKITEXT, + WikiPageTest::$sections, + "new", + "No more", + "New", + trim( WikiPageTest::$sections ) . "\n\n== New ==\n\nNo more" + ), + ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSection + */ + public function testReplaceSection( $title, $model, $text, $section, $with, + $sectionTitle, $expected + ) { + $this->hideDeprecated( "WikiPage::replaceSection" ); + + $page = $this->createPage( $title, $text, $model ); + $text = $page->replaceSection( $section, $with, $sectionTitle ); + $text = trim( $text ); + + $this->assertEquals( $expected, $text ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSectionContent + */ + public function testReplaceSectionContent( $title, $model, $text, $section, + $with, $sectionTitle, $expected + ) { + $page = $this->createPage( $title, $text, $model ); + + $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() ); + $c = $page->replaceSectionContent( $section, $content, $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikiPage::replaceSectionAtRev + */ + public function testReplaceSectionAtRev( $title, $model, $text, $section, + $with, $sectionTitle, $expected + ) { + $page = $this->createPage( $title, $text, $model ); + $baseRevId = $page->getLatest(); + + $content = ContentHandler::makeContent( $with, $page->getTitle(), $page->getContentModel() ); + $c = $page->replaceSectionAtRev( $section, $content, $sectionTitle, $baseRevId ); + + $this->assertEquals( $expected, is_null( $c ) ? null : trim( $c->getNativeData() ) ); + } + + /* @todo FIXME: fix this! + public function testGetUndoText() { + $this->checkHasDiff3(); + + $text = "one"; + $page = $this->createPage( "WikiPageTest_testGetUndoText", $text ); + $rev1 = $page->getRevision(); + + $text .= "\n\ntwo"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section two" + ); + $rev2 = $page->getRevision(); + + $text .= "\n\nthree"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section three" + ); + $rev3 = $page->getRevision(); + + $text .= "\n\nfour"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section four" + ); + $rev4 = $page->getRevision(); + + $text .= "\n\nfive"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section five" + ); + $rev5 = $page->getRevision(); + + $text .= "\n\nsix"; + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section six" + ); + $rev6 = $page->getRevision(); + + $undo6 = $page->getUndoText( $rev6 ); + if ( $undo6 === false ) $this->fail( "getUndoText failed for rev6" ); + $this->assertEquals( "one\n\ntwo\n\nthree\n\nfour\n\nfive", $undo6 ); + + $undo3 = $page->getUndoText( $rev4, $rev2 ); + if ( $undo3 === false ) $this->fail( "getUndoText failed for rev4..rev2" ); + $this->assertEquals( "one\n\ntwo\n\nfive", $undo3 ); + + $undo2 = $page->getUndoText( $rev2 ); + if ( $undo2 === false ) $this->fail( "getUndoText failed for rev2" ); + $this->assertEquals( "one\n\nfive", $undo2 ); + } + */ + + /** + * @todo FIXME: this is a better rollback test than the one below, but it + * keeps failing in jenkins for some reason. + */ + public function broken_testDoRollback() { + $admin = new User(); + $admin->setName( "Admin" ); + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "section one", EDIT_NEW, false, $admin ); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section two", 0, false, $user1 ); + + $user2 = new User(); + $user2->setName( "127.0.2.13" ); + $text .= "\n\nthree"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), + "adding section three", 0, false, $user2 ); + + # we are having issues with doRollback spuriously failing. Apparently + # the last revision somehow goes missing or not committed under some + # circumstances. So, make sure the last revision has the right user name. + $dbr = wfGetDB( DB_SLAVE ); + $this->assertEquals( 3, Revision::countByPageId( $dbr, $page->getId() ) ); + + $page = new WikiPage( $page->getTitle() ); + $rev3 = $page->getRevision(); + $this->assertEquals( '127.0.2.13', $rev3->getUserText() ); + + $rev2 = $rev3->getPrevious(); + $this->assertEquals( '127.0.1.11', $rev2->getUserText() ); + + $rev1 = $rev2->getPrevious(); + $this->assertEquals( 'Admin', $rev1->getUserText() ); + + # now, try the actual rollback + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user2->getName() ), + null + ); + $errors = $page->doRollback( + $user2->getName(), + "testing revert", + $token, + false, + $details, + $admin + ); + + if ( $errors ) { + $this->fail( "Rollback failed:\n" . print_r( $errors, true ) + . ";\n" . print_r( $details, true ) ); + } + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev2->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one\n\ntwo", $page->getContent()->getNativeData() ); + } + + /** + * @todo FIXME: the above rollback test is better, but it keeps failing in jenkins for some reason. + * @covers WikiPage::doRollback + */ + public function testDoRollback() { + $admin = new User(); + $admin->setName( "Admin" ); + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "section one", + EDIT_NEW, + false, + $admin + ); + $rev1 = $page->getRevision(); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "adding section two", + 0, + false, + $user1 + ); + + # now, try the rollback + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert", + $token, + false, + $details, + $admin + ); + + if ( $errors ) { + $this->fail( "Rollback failed:\n" . print_r( $errors, true ) + . ";\n" . print_r( $details, true ) ); + } + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one", $page->getContent()->getNativeData() ); + } + + /** + * @covers WikiPage::doRollback + */ + public function testDoRollbackFailureSameContent() { + $admin = new User(); + $admin->setName( "Admin" ); + $admin->addGroup( "sysop" ); #XXX: make the test user a sysop... + + $text = "one"; + $page = $this->newPage( "WikiPageTest_testDoRollback" ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "section one", + EDIT_NEW, + false, + $admin + ); + $rev1 = $page->getRevision(); + + $user1 = new User(); + $user1->setName( "127.0.1.11" ); + $user1->addGroup( "sysop" ); #XXX: make the test user a sysop... + $text .= "\n\ntwo"; + $page = new WikiPage( $page->getTitle() ); + $page->doEditContent( + ContentHandler::makeContent( $text, $page->getTitle(), CONTENT_MODEL_WIKITEXT ), + "adding section two", + 0, + false, + $user1 + ); + + # now, do a the rollback from the same user was doing the edit before + $resultDetails = array(); + $token = $user1->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert same user", + $token, + false, + $resultDetails, + $admin + ); + + $this->assertEquals( array(), $errors, "Rollback failed same user" ); + + # now, try the rollback + $resultDetails = array(); + $token = $admin->getEditToken( + array( $page->getTitle()->getPrefixedText(), $user1->getName() ), + null + ); + $errors = $page->doRollback( + $user1->getName(), + "testing revert", + $token, + false, + $resultDetails, + $admin + ); + + $this->assertEquals( array( array( 'alreadyrolled', 'WikiPageTest testDoRollback', + '127.0.1.11', 'Admin' ) ), $errors, "Rollback not failed" ); + + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( $rev1->getSha1(), $page->getRevision()->getSha1(), + "rollback did not revert to the correct revision" ); + $this->assertEquals( "one", $page->getContent()->getNativeData() ); + } + + public static function provideGetAutosummary() { + return array( + array( + 'Hello there, world!', + '#REDIRECT [[Foo]]', + 0, + '/^Redirected page .*Foo/' + ), + + array( + null, + 'Hello world!', + EDIT_NEW, + '/^Created page .*Hello/' + ), + + array( + 'Hello there, world!', + '', + 0, + '/^Blanked/' + ), + + array( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Hello world!', + 0, + '/^Replaced .*Hello/' + ), + + array( + 'foo', + 'bar', + 0, + '/^$/' + ), + ); + } + + /** + * @dataProvider provideGetAutoSummary + * @covers WikiPage::getAutosummary + */ + public function testGetAutosummary( $old, $new, $flags, $expected ) { + $this->hideDeprecated( "WikiPage::getAutosummary" ); + + $page = $this->newPage( "WikiPageTest_testGetAutosummary" ); + + $summary = $page->getAutosummary( $old, $new, $flags ); + + $this->assertTrue( (bool)preg_match( $expected, $summary ), + "Autosummary didn't match expected pattern $expected: $summary" ); + } + + public static function provideGetAutoDeleteReason() { + return array( + array( + array(), + false, + false + ), + + array( + array( + array( "first edit", null ), + ), + "/first edit.*only contributor/", + false + ), + + array( + array( + array( "first edit", null ), + array( "second edit", null ), + ), + "/second edit.*only contributor/", + true + ), + + array( + array( + array( "first edit", "127.0.2.22" ), + array( "second edit", "127.0.3.33" ), + ), + "/second edit/", + true + ), + + array( + array( + array( + "first edit: " + . "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam " + . " nonumy eirmod tempor invidunt ut labore et dolore magna " + . "aliquyam erat, sed diam voluptua. At vero eos et accusam " + . "et justo duo dolores et ea rebum. Stet clita kasd gubergren, " + . "no sea takimata sanctus est Lorem ipsum dolor sit amet.'", + null + ), + ), + '/first edit:.*\.\.\."/', + false + ), + + array( + array( + array( "first edit", "127.0.2.22" ), + array( "", "127.0.3.33" ), + ), + "/before blanking.*first edit/", + true + ), + + ); + } + + /** + * @dataProvider provideGetAutoDeleteReason + * @covers WikiPage::getAutoDeleteReason + */ + public function testGetAutoDeleteReason( $edits, $expectedResult, $expectedHistory ) { + global $wgUser; + + //NOTE: assume Help namespace to contain wikitext + $page = $this->newPage( "Help:WikiPageTest_testGetAutoDeleteReason" ); + + $c = 1; + + foreach ( $edits as $edit ) { + $user = new User(); + + if ( !empty( $edit[1] ) ) { + $user->setName( $edit[1] ); + } else { + $user = $wgUser; + } + + $content = ContentHandler::makeContent( $edit[0], $page->getTitle(), $page->getContentModel() ); + + $page->doEditContent( $content, "test edit $c", $c < 2 ? EDIT_NEW : 0, false, $user ); + + $c += 1; + } + + $reason = $page->getAutoDeleteReason( $hasHistory ); + + if ( is_bool( $expectedResult ) || is_null( $expectedResult ) ) { + $this->assertEquals( $expectedResult, $reason ); + } else { + $this->assertTrue( (bool)preg_match( $expectedResult, $reason ), + "Autosummary didn't match expected pattern $expectedResult: $reason" ); + } + + $this->assertEquals( $expectedHistory, $hasHistory, + "expected \$hasHistory to be " . var_export( $expectedHistory, true ) ); + + $page->doDeleteArticle( "done" ); + } + + public static function providePreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + ), + ); + } + + /** + * @dataProvider providePreSaveTransform + * @covers WikiPage::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + $this->hideDeprecated( 'WikiPage::preSaveTransform' ); + $user = new User(); + $user->setName( "127.0.0.1" ); + + //NOTE: assume Help namespace to contain wikitext + $page = $this->newPage( "Help:WikiPageTest_testPreloadTransform" ); + $text = $page->preSaveTransform( $text, $user ); + + $this->assertEquals( $expected, $text ); + } + + /** + * @covers WikiPage::factory + */ + public function testWikiPageFactory() { + $title = Title::makeTitle( NS_FILE, 'Someimage.png' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiFilePage', get_class( $page ) ); + + $title = Title::makeTitle( NS_CATEGORY, 'SomeCategory' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiCategoryPage', get_class( $page ) ); + + $title = Title::makeTitle( NS_MAIN, 'SomePage' ); + $page = WikiPage::factory( $title ); + $this->assertEquals( 'WikiPage', get_class( $page ) ); + } +} diff --git a/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php b/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php new file mode 100644 index 00000000..3db76280 --- /dev/null +++ b/tests/phpunit/includes/WikiPageTestContentHandlerUseDB.php @@ -0,0 +1,61 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- important, causes temporary tables to be used instead of the real database + */ +class WikiPageTestContentHandlerUseDB extends WikiPageTest { + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( 'wgContentHandlerUseDB', false ); + + $dbw = wfGetDB( DB_MASTER ); + + $page_table = $dbw->tableName( 'page' ); + $revision_table = $dbw->tableName( 'revision' ); + $archive_table = $dbw->tableName( 'archive' ); + + if ( $dbw->fieldExists( $page_table, 'page_content_model' ) ) { + $dbw->query( "alter table $page_table drop column page_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_model" ); + $dbw->query( "alter table $revision_table drop column rev_content_format" ); + $dbw->query( "alter table $archive_table drop column ar_content_model" ); + $dbw->query( "alter table $archive_table drop column ar_content_format" ); + } + } + + /** + * @covers WikiPage::getContentModel + */ + public function testGetContentModel() { + $page = $this->createPage( + "WikiPageTest_testGetContentModel", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + $page = new WikiPage( $page->getTitle() ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $page->getContentModel() ); + } + + /** + * @covers WikiPage::getContentHandler + */ + public function testGetContentHandler() { + $page = $this->createPage( + "WikiPageTest_testGetContentHandler", + "some text", + CONTENT_MODEL_JAVASCRIPT + ); + + // NOTE: since the content model is not recorded in the database, + // we expect to get the default, namely CONTENT_MODEL_WIKITEXT + $page = new WikiPage( $page->getTitle() ); + $this->assertEquals( 'WikitextContentHandler', get_class( $page->getContentHandler() ) ); + } +} diff --git a/tests/phpunit/includes/XmlJsTest.php b/tests/phpunit/includes/XmlJsTest.php new file mode 100644 index 00000000..0dbb0109 --- /dev/null +++ b/tests/phpunit/includes/XmlJsTest.php @@ -0,0 +1,24 @@ +<?php + +/** + * @group Xml + */ +class XmlJs extends MediaWikiTestCase { + + /** + * @covers XmlJsCode::__construct + * @dataProvider provideConstruction + */ + public function testConstruction( $value ) { + $obj = new XmlJsCode( $value ); + $this->assertEquals( $value, $obj->value ); + } + + public static function provideConstruction() { + return array( + array( null ), + array( '' ), + ); + } + +} diff --git a/tests/phpunit/includes/XmlSelectTest.php b/tests/phpunit/includes/XmlSelectTest.php new file mode 100644 index 00000000..9f154bb7 --- /dev/null +++ b/tests/phpunit/includes/XmlSelectTest.php @@ -0,0 +1,185 @@ +<?php + +/** + * @group Xml + */ +class XmlSelectTest extends MediaWikiTestCase { + + /** + * @var XmlSelect + */ + protected $select; + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( array( + 'wgWellFormedXml' => true, + ) ); + $this->select = new XmlSelect(); + } + + protected function tearDown() { + parent::tearDown(); + $this->select = null; + } + + /** + * @covers XmlSelect::__construct + */ + public function testConstructWithoutParameters() { + $this->assertEquals( '<select></select>', $this->select->getHTML() ); + } + + /** + * Parameters are $name (false), $id (false), $default (false) + * @dataProvider provideConstructionParameters + * @covers XmlSelect::__construct + */ + public function testConstructParameters( $name, $id, $default, $expected ) { + $this->select = new XmlSelect( $name, $id, $default ); + $this->assertEquals( $expected, $this->select->getHTML() ); + } + + /** + * Provide parameters for testConstructParameters() which use three + * parameters: + * - $name (default: false) + * - $id (default: false) + * - $default (default: false) + * Provides a fourth parameters representing the expected HTML output + */ + public static function provideConstructionParameters() { + return array( + /** + * Values are set following a 3-bit Gray code where two successive + * values differ by only one value. + * See http://en.wikipedia.org/wiki/Gray_code + */ + # $name $id $default + array( false, false, false, '<select></select>' ), + array( false, false, 'foo', '<select></select>' ), + array( false, 'id', 'foo', '<select id="id"></select>' ), + array( false, 'id', false, '<select id="id"></select>' ), + array( 'name', 'id', false, '<select name="name" id="id"></select>' ), + array( 'name', 'id', 'foo', '<select name="name" id="id"></select>' ), + array( 'name', false, 'foo', '<select name="name"></select>' ), + array( 'name', false, false, '<select name="name"></select>' ), + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOption() { + $this->select->addOption( 'foo' ); + $this->assertEquals( + '<select><option value="foo">foo</option></select>', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithDefault() { + $this->select->addOption( 'foo', true ); + $this->assertEquals( + '<select><option value="1">foo</option></select>', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithFalse() { + $this->select->addOption( 'foo', false ); + $this->assertEquals( + '<select><option value="foo">foo</option></select>', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithValueZero() { + $this->select->addOption( 'foo', 0 ); + $this->assertEquals( + '<select><option value="0">foo</option></select>', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::setDefault + */ + public function testSetDefault() { + $this->select->setDefault( 'bar1' ); + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->assertEquals( + '<select><option value="foo1">foo1</option>' . "\n" . + '<option value="bar1" selected="">bar1</option>' . "\n" . + '<option value="foo2">foo2</option></select>', $this->select->getHTML() ); + } + + /** + * Adding default later on should set the correct selection or + * raise an exception. + * To handle this, we need to render the options in getHtml() + * @covers XmlSelect::setDefault + */ + public function testSetDefaultAfterAddingOptions() { + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->select->setDefault( 'bar1' ); # setting default after adding options + $this->assertEquals( + '<select><option value="foo1">foo1</option>' . "\n" . + '<option value="bar1" selected="">bar1</option>' . "\n" . + '<option value="foo2">foo2</option></select>', $this->select->getHTML() ); + } + + /** + * @covers XmlSelect::setAttribute + * @covers XmlSelect::getAttribute + */ + public function testGetAttributes() { + # create some attributes + $this->select->setAttribute( 'dummy', 0x777 ); + $this->select->setAttribute( 'string', 'euro €' ); + $this->select->setAttribute( 1911, 'razor' ); + + # verify we can retrieve them + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + $this->assertEquals( + $this->select->getAttribute( 'string' ), + 'euro €' + ); + $this->assertEquals( + $this->select->getAttribute( 1911 ), + 'razor' + ); + + # inexistant keys should give us 'null' + $this->assertEquals( + $this->select->getAttribute( 'I DO NOT EXIT' ), + null + ); + + # verify string / integer + $this->assertEquals( + $this->select->getAttribute( '1911' ), + 'razor' + ); + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + } +} diff --git a/tests/phpunit/includes/XmlTest.php b/tests/phpunit/includes/XmlTest.php new file mode 100644 index 00000000..e6558819 --- /dev/null +++ b/tests/phpunit/includes/XmlTest.php @@ -0,0 +1,411 @@ +<?php + +/** + * @group Xml + */ +class XmlTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $langObj = Language::factory( 'en' ); + $langObj->setNamespaces( array( + -2 => 'Media', + -1 => 'Special', + 0 => '', + 1 => 'Talk', + 2 => 'User', + 3 => 'User_talk', + 4 => 'MyWiki', + 5 => 'MyWiki_Talk', + 6 => 'File', + 7 => 'File_talk', + 8 => 'MediaWiki', + 9 => 'MediaWiki_talk', + 10 => 'Template', + 11 => 'Template_talk', + 100 => 'Custom', + 101 => 'Custom_talk', + ) ); + + $this->setMwGlobals( array( + 'wgLang' => $langObj, + 'wgWellFormedXml' => true, + ) ); + } + + /** + * @covers Xml::expandAttributes + */ + public function testExpandAttributes() { + $this->assertNull( Xml::expandAttributes( null ), + 'Converting a null list of attributes' + ); + $this->assertEquals( '', Xml::expandAttributes( array() ), + 'Converting an empty list of attributes' + ); + } + + /** + * @covers Xml::expandAttributes + */ + public function testExpandAttributesException() { + $this->setExpectedException( 'MWException' ); + Xml::expandAttributes( 'string' ); + } + + /** + * @covers Xml::element + */ + public function testElementOpen() { + $this->assertEquals( + '<element>', + Xml::element( 'element', null, null ), + 'Opening element with no attributes' + ); + } + + /** + * @covers Xml::element + */ + public function testElementEmpty() { + $this->assertEquals( + '<element />', + Xml::element( 'element', null, '' ), + 'Terminated empty element' + ); + } + + /** + * @covers Xml::input + */ + public function testElementInputCanHaveAValueOfZero() { + $this->assertEquals( + '<input name="name" value="0" class="mw-ui-input" />', + Xml::input( 'name', false, 0 ), + 'Input with a value of 0 (bug 23797)' + ); + } + + /** + * @covers Xml::element + */ + public function testElementEscaping() { + $this->assertEquals( + '<element>hello <there> you & you</element>', + Xml::element( 'element', null, 'hello <there> you & you' ), + 'Element with no attributes and content that needs escaping' + ); + } + + /** + * @covers Xml::escapeTagsOnly + */ + public function testEscapeTagsOnly() { + $this->assertEquals( '"><', Xml::escapeTagsOnly( '"><' ), + 'replace " > and < with their HTML entitites' + ); + } + + /** + * @covers Xml::element + */ + public function testElementAttributes() { + $this->assertEquals( + '<element key="value" <>="<>">', + Xml::element( 'element', array( 'key' => 'value', '<>' => '<>' ), null ), + 'Element attributes, keys are not escaped' + ); + } + + /** + * @covers Xml::openElement + */ + public function testOpenElement() { + $this->assertEquals( + '<element k="v">', + Xml::openElement( 'element', array( 'k' => 'v' ) ), + 'openElement() shortcut' + ); + } + + /** + * @covers Xml::closeElement + */ + public function testCloseElement() { + $this->assertEquals( '</element>', Xml::closeElement( 'element' ), 'closeElement() shortcut' ); + } + + /** + * @covers Xml::dateMenu + */ + public function testDateMenu() { + $curYear = intval( gmdate( 'Y' ) ); + $prevYear = $curYear - 1; + + $curMonth = intval( gmdate( 'n' ) ); + + $nextMonth = $curMonth + 1; + if ( $nextMonth == 13 ) { + $nextMonth = 1; + } + + $this->assertEquals( + '<label for="year">From year (and earlier):</label> ' . + '<input id="year" maxlength="4" size="7" type="number" value="2011" name="year" class="mw-ui-input" /> ' . + '<label for="month">From month (and earlier):</label> ' . + '<select id="month" name="month" class="mw-month-selector">' . + '<option value="-1">all</option>' . "\n" . + '<option value="1">January</option>' . "\n" . + '<option value="2" selected="">February</option>' . "\n" . + '<option value="3">March</option>' . "\n" . + '<option value="4">April</option>' . "\n" . + '<option value="5">May</option>' . "\n" . + '<option value="6">June</option>' . "\n" . + '<option value="7">July</option>' . "\n" . + '<option value="8">August</option>' . "\n" . + '<option value="9">September</option>' . "\n" . + '<option value="10">October</option>' . "\n" . + '<option value="11">November</option>' . "\n" . + '<option value="12">December</option></select>', + Xml::dateMenu( 2011, 02 ), + "Date menu for february 2011" + ); + $this->assertEquals( + '<label for="year">From year (and earlier):</label> ' . + '<input id="year" maxlength="4" size="7" type="number" value="2011" name="year" class="mw-ui-input" /> ' . + '<label for="month">From month (and earlier):</label> ' . + '<select id="month" name="month" class="mw-month-selector">' . + '<option value="-1">all</option>' . "\n" . + '<option value="1">January</option>' . "\n" . + '<option value="2">February</option>' . "\n" . + '<option value="3">March</option>' . "\n" . + '<option value="4">April</option>' . "\n" . + '<option value="5">May</option>' . "\n" . + '<option value="6">June</option>' . "\n" . + '<option value="7">July</option>' . "\n" . + '<option value="8">August</option>' . "\n" . + '<option value="9">September</option>' . "\n" . + '<option value="10">October</option>' . "\n" . + '<option value="11">November</option>' . "\n" . + '<option value="12">December</option></select>', + Xml::dateMenu( 2011, -1 ), + "Date menu with negative month for 'All'" + ); + $this->assertEquals( + Xml::dateMenu( $curYear, $curMonth ), + Xml::dateMenu( '', $curMonth ), + "Date menu year is the current one when not specified" + ); + + $wantedYear = $nextMonth == 1 ? $curYear : $prevYear; + $this->assertEquals( + Xml::dateMenu( $wantedYear, $nextMonth ), + Xml::dateMenu( '', $nextMonth ), + "Date menu next month is 11 months ago" + ); + + $this->assertEquals( + '<label for="year">From year (and earlier):</label> ' . + '<input id="year" maxlength="4" size="7" type="number" name="year" class="mw-ui-input" /> ' . + '<label for="month">From month (and earlier):</label> ' . + '<select id="month" name="month" class="mw-month-selector">' . + '<option value="-1">all</option>' . "\n" . + '<option value="1">January</option>' . "\n" . + '<option value="2">February</option>' . "\n" . + '<option value="3">March</option>' . "\n" . + '<option value="4">April</option>' . "\n" . + '<option value="5">May</option>' . "\n" . + '<option value="6">June</option>' . "\n" . + '<option value="7">July</option>' . "\n" . + '<option value="8">August</option>' . "\n" . + '<option value="9">September</option>' . "\n" . + '<option value="10">October</option>' . "\n" . + '<option value="11">November</option>' . "\n" . + '<option value="12">December</option></select>', + Xml::dateMenu( '', '' ), + "Date menu with neither year or month" + ); + } + + /** + * @covers Xml::textarea + */ + public function testTextareaNoContent() { + $this->assertEquals( + '<textarea name="name" id="name" cols="40" rows="5" class="mw-ui-input"></textarea>', + Xml::textarea( 'name', '' ), + 'textarea() with not content' + ); + } + + /** + * @covers Xml::textarea + */ + public function testTextareaAttribs() { + $this->assertEquals( + '<textarea name="name" id="name" cols="20" rows="10" class="mw-ui-input"><txt></textarea>', + Xml::textarea( 'name', '<txt>', 20, 10 ), + 'textarea() with custom attribs' + ); + } + + /** + * @covers Xml::label + */ + public function testLabelCreation() { + $this->assertEquals( + '<label for="id">name</label>', + Xml::label( 'name', 'id' ), + 'label() with no attribs' + ); + } + + /** + * @covers Xml::label + */ + public function testLabelAttributeCanOnlyBeClassOrTitle() { + $this->assertEquals( + '<label for="id">name</label>', + Xml::label( 'name', 'id', array( 'generated' => true ) ), + 'label() can not be given a generated attribute' + ); + $this->assertEquals( + '<label for="id" class="nice">name</label>', + Xml::label( 'name', 'id', array( 'class' => 'nice' ) ), + 'label() can get a class attribute' + ); + $this->assertEquals( + '<label for="id" title="nice tooltip">name</label>', + Xml::label( 'name', 'id', array( 'title' => 'nice tooltip' ) ), + 'label() can get a title attribute' + ); + $this->assertEquals( + '<label for="id" class="nice" title="nice tooltip">name</label>', + Xml::label( 'name', 'id', array( + 'generated' => true, + 'class' => 'nice', + 'title' => 'nice tooltip', + 'anotherattr' => 'value', + ) + ), + 'label() skip all attributes but "class" and "title"' + ); + } + + /** + * @covers Xml::languageSelector + */ + public function testLanguageSelector() { + $select = Xml::languageSelector( 'en', true, null, + array( 'id' => 'testlang' ), wfMessage( 'yourlanguage' ) ); + $this->assertEquals( + '<label for="testlang">Language:</label>', + $select[0] + ); + } + + /** + * @covers Xml::escapeJsString + */ + public function testEscapeJsStringSpecialChars() { + $this->assertEquals( + '\\\\\r\n', + Xml::escapeJsString( "\\\r\n" ), + 'escapeJsString() with special characters' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarBoolean() { + $this->assertEquals( + 'true', + Xml::encodeJsVar( true ), + 'encodeJsVar() with boolean' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarNull() { + $this->assertEquals( + 'null', + Xml::encodeJsVar( null ), + 'encodeJsVar() with null' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarArray() { + $this->assertEquals( + '["a",1]', + Xml::encodeJsVar( array( 'a', 1 ) ), + 'encodeJsVar() with array' + ); + $this->assertEquals( + '{"a":"a","b":1}', + Xml::encodeJsVar( array( 'a' => 'a', 'b' => 1 ) ), + 'encodeJsVar() with associative array' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarObject() { + $this->assertEquals( + '{"a":"a","b":1}', + Xml::encodeJsVar( (object)array( 'a' => 'a', 'b' => 1 ) ), + 'encodeJsVar() with object' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarInt() { + $this->assertEquals( + '123456', + Xml::encodeJsVar( 123456 ), + 'encodeJsVar() with int' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarFloat() { + $this->assertEquals( + '1.23456', + Xml::encodeJsVar( 1.23456 ), + 'encodeJsVar() with float' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarIntString() { + $this->assertEquals( + '"123456"', + Xml::encodeJsVar( '123456' ), + 'encodeJsVar() with int-like string' + ); + } + + /** + * @covers Xml::encodeJsVar + */ + public function testEncodeJsVarFloatString() { + $this->assertEquals( + '"1.23456"', + Xml::encodeJsVar( '1.23456' ), + 'encodeJsVar() with float-like string' + ); + } +} diff --git a/tests/phpunit/includes/XmlTypeCheckTest.php b/tests/phpunit/includes/XmlTypeCheckTest.php new file mode 100644 index 00000000..6ad97fd4 --- /dev/null +++ b/tests/phpunit/includes/XmlTypeCheckTest.php @@ -0,0 +1,49 @@ +<?php +/** + * PHPUnit tests for XMLTypeCheck. + * @author physikerwelt + * @group Xml + * @covers XMLTypeCheck + */ +class XmlTypeCheckTest extends MediaWikiTestCase { + const WELL_FORMED_XML = "<root><child /></root>"; + const MAL_FORMED_XML = "<root><child /></error>"; + const XML_WITH_PIH = '<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="/w/index.php"?><svg><child /></svg>'; + + /** + * @covers XMLTypeCheck::newFromString + * @covers XMLTypeCheck::getRootElement + */ + public function testWellFormedXML() { + $testXML = XmlTypeCheck::newFromString( self::WELL_FORMED_XML ); + $this->assertTrue( $testXML->wellFormed ); + $this->assertEquals( 'root', $testXML->getRootElement() ); + } + + /** + * @covers XMLTypeCheck::newFromString + */ + public function testMalFormedXML() { + $testXML = XmlTypeCheck::newFromString( self::MAL_FORMED_XML ); + $this->assertFalse( $testXML->wellFormed ); + } + + /** + * @covers XMLTypeCheck::processingInstructionHandler + */ + public function testProcessingInstructionHandler() { + $called = false; + $testXML = new XmlTypeCheck( + self::XML_WITH_PIH, + null, + false, + array( + 'processing_instruction_handler' => function() use ( &$called ) { + $called = true; + } + ) + ); + $this->assertTrue( $called ); + } + +} diff --git a/tests/phpunit/includes/actions/ActionTest.php b/tests/phpunit/includes/actions/ActionTest.php new file mode 100644 index 00000000..cc6fb11a --- /dev/null +++ b/tests/phpunit/includes/actions/ActionTest.php @@ -0,0 +1,199 @@ +<?php + +/** + * @covers Action + * + * @licence GNU GPL v2+ + * @author Thiemo Mättig + * + * @group Action + * @group Database + */ +class ActionTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $context = $this->getContext(); + $this->setMwGlobals( 'wgActions', array( + 'null' => null, + 'disabled' => false, + 'view' => true, + 'edit' => true, + 'revisiondelete' => true, + 'dummy' => true, + 'string' => 'NamedDummyAction', + 'declared' => 'NonExistingClassName', + 'callable' => array( $this, 'dummyActionCallback' ), + 'object' => new InstantiatedDummyAction( $context->getWikiPage(), $context ), + ) ); + } + + private function getPage() { + return WikiPage::factory( Title::makeTitle( 0, 'Title' ) ); + } + + private function getContext( $requestedAction = null ) { + $request = new FauxRequest( array( 'action' => $requestedAction ) ); + + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( $request ); + $context->setWikiPage( $this->getPage() ); + + return $context; + } + + public function actionProvider() { + return array( + array( 'dummy', 'DummyAction' ), + array( 'string', 'NamedDummyAction' ), + array( 'callable', 'CalledDummyAction' ), + array( 'object', 'InstantiatedDummyAction' ), + + // Capitalization is ignored + array( 'DUMMY', 'DummyAction' ), + array( 'STRING', 'NamedDummyAction' ), + + // Null and non-existing values + array( 'null', null ), + array( 'undeclared', null ), + array( '', null ), + array( false, null ), + ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testActionExists( $requestedAction, $expected ) { + $exists = Action::exists( $requestedAction ); + + $this->assertSame( $expected !== null, $exists ); + } + + public function testActionExists_doesNotRequireInstantiation() { + // The method is not supposed to check if the action can be instantiated. + $exists = Action::exists( 'declared' ); + + $this->assertTrue( $exists ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testGetActionName( $requestedAction, $expected ) { + $context = $this->getContext( $requestedAction ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( $expected ?: 'nosuchaction', $actionName ); + } + + public function testGetActionName_editredlinkWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'editredlink' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'edit', $actionName ); + } + + public function testGetActionName_historysubmitWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'historysubmit' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'view', $actionName ); + } + + public function testGetActionName_revisiondeleteWorkaround() { + // See https://bugzilla.wikimedia.org/show_bug.cgi?id=20966 + $context = $this->getContext( 'historysubmit' ); + $context->getRequest()->setVal( 'revisiondelete', true ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'revisiondelete', $actionName ); + } + + /** + * @dataProvider actionProvider + * @param string $requestedAction + * @param string|null $expected + */ + public function testActionFactory( $requestedAction, $expected ) { + $context = $this->getContext(); + $action = Action::factory( $requestedAction, $context->getWikiPage(), $context ); + + $this->assertType( $expected ?: 'null', $action ); + } + + public function testNull_doesNotExist() { + $exists = Action::exists( null ); + + $this->assertFalse( $exists ); + } + + public function testNull_defaultsToView() { + $context = $this->getContext( null ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'view', $actionName ); + } + + public function testNull_canNotBeInstantiated() { + $page = $this->getPage(); + $action = Action::factory( null, $page ); + + $this->assertNull( $action ); + } + + public function testDisabledAction_exists() { + $exists = Action::exists( 'disabled' ); + + $this->assertTrue( $exists ); + } + + public function testDisabledAction_isNotResolved() { + $context = $this->getContext( 'disabled' ); + $actionName = Action::getActionName( $context ); + + $this->assertEquals( 'nosuchaction', $actionName ); + } + + public function testDisabledAction_factoryReturnsFalse() { + $page = $this->getPage(); + $action = Action::factory( 'disabled', $page ); + + $this->assertFalse( $action ); + } + + public function dummyActionCallback() { + $context = $this->getContext(); + return new CalledDummyAction( $context->getWikiPage(), $context ); + } + +} + +class DummyAction extends Action { + + public function getName() { + return get_called_class(); + } + + public function show() { + } + + public function execute() { + } +} + +class NamedDummyAction extends DummyAction { +} + +class CalledDummyAction extends DummyAction { +} + +class InstantiatedDummyAction extends DummyAction { +} diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php new file mode 100644 index 00000000..a05c4fa8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -0,0 +1,46 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + */ +class ApiBaseTest extends ApiTestCase { + + /** + * @covers ApiBase::requireOnlyOneParameter + */ + public function testRequireOnlyOneParameterDefault() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => false ), + "filename", "enablechunks" + ); + $this->assertTrue( true ); + } + + /** + * @expectedException UsageException + * @covers ApiBase::requireOnlyOneParameter + */ + public function testRequireOnlyOneParameterZero() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => 0 ), + "filename", "enablechunks" + ); + } + + /** + * @expectedException UsageException + * @covers ApiBase::requireOnlyOneParameter + */ + public function testRequireOnlyOneParameterTrue() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + array( "filename" => "foo.txt", "enablechunks" => true ), + "filename", "enablechunks" + ); + } + +} diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php new file mode 100644 index 00000000..d98eec6a --- /dev/null +++ b/tests/phpunit/includes/api/ApiBlockTest.php @@ -0,0 +1,83 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiBlock + */ +class ApiBlockTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + protected function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + function addDBData() { + $user = User::newFromName( 'UTApiBlockee' ); + + if ( $user->getId() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTApiBlockeePassword' ); + + $user->saveSettings(); + } + } + + /** + * This test has probably always been broken and use an invalid token + * Bug tracking brokenness is https://bugzilla.wikimedia.org/35646 + * + * Root cause is https://gerrit.wikimedia.org/r/3434 + * Which made the Block/Unblock API to actually verify the token + * previously always considered valid (bug 34212). + */ + public function testMakeNormalBlock() { + $tokens = $this->getTokens(); + + $user = User::newFromName( 'UTApiBlockee' ); + + if ( !$user->getId() ) { + $this->markTestIncomplete( "The user UTApiBlockee does not exist" ); + } + + if ( !array_key_exists( 'blocktoken', $tokens ) ) { + $this->markTestIncomplete( "No block token found" ); + } + + $this->doApiRequest( array( + 'action' => 'block', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + 'token' => $tokens['blocktoken'] ), null, false, self::$users['sysop']->user ); + + $block = Block::newFromTarget( 'UTApiBlockee' ); + + $this->assertTrue( !is_null( $block ), 'Block is valid' ); + + $this->assertEquals( 'UTApiBlockee', (string)$block->getTarget() ); + $this->assertEquals( 'Some reason', $block->mReason ); + $this->assertEquals( 'infinity', $block->mExpiry ); + } + + /** + * @expectedException UsageException + * @expectedExceptionMessage The token parameter must be set + */ + public function testBlockingActionWithNoToken( ) { + $this->doApiRequest( + array( + 'action' => 'block', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + ), + null, + false, + self::$users['sysop']->user + ); + } +} diff --git a/tests/phpunit/includes/api/ApiCreateAccountTest.php b/tests/phpunit/includes/api/ApiCreateAccountTest.php new file mode 100644 index 00000000..8d134f76 --- /dev/null +++ b/tests/phpunit/includes/api/ApiCreateAccountTest.php @@ -0,0 +1,161 @@ +<?php + +/** + * @group Database + * @group API + * @group medium + * + * @covers ApiCreateAccount + */ +class ApiCreateAccountTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + LoginForm::setCreateaccountToken(); + $this->setMwGlobals( array( 'wgEnableEmail' => true ) ); + } + + /** + * Test the account creation API with a valid request. Also + * make sure the new account can log in and is valid. + * + * This test does multiple API requests so it might end up being + * a bit slow. Raise the default timeout. + * @group medium + */ + public function testValid() { + global $wgServer; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $password = User::randomPassword(); + + $ret = $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Apitestnew', + 'password' => $password, + 'email' => 'test@domain.test', + 'realname' => 'Test Name' + ) ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['createaccount'] ); + + // Should first ask for token. + $a = $result['createaccount']; + $this->assertEquals( 'NeedToken', $a['result'] ); + $token = $a['token']; + + // Finally create the account + $ret = $this->doApiRequest( + array( + 'action' => 'createaccount', + 'name' => 'Apitestnew', + 'password' => $password, + 'token' => $token, + 'email' => 'test@domain.test', + 'realname' => 'Test Name' + ), + $ret[2] + ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertEquals( 'Success', $result['createaccount']['result'] ); + + // Try logging in with the new user. + $ret = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => 'Apitestnew', + 'lgpassword' => $password, + ) ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['login'] ); + + $a = $result['login']['result']; + $this->assertEquals( 'NeedToken', $a ); + $token = $result['login']['token']; + + $ret = $this->doApiRequest( + array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => 'Apitestnew', + 'lgpassword' => $password, + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( 'bool', $result ); + $a = $result['login']['result']; + + $this->assertEquals( 'Success', $a ); + + // log out to destroy the session + $ret = $this->doApiRequest( + array( + 'action' => 'logout', + ), + $ret[2] + ); + $this->assertEquals( array(), $ret[0] ); + } + + /** + * Make sure requests with no names are invalid. + * @expectedException UsageException + */ + public function testNoName() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + ) ); + } + + /** + * Make sure requests with no password are invalid. + * @expectedException UsageException + */ + public function testNoPassword() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'testName', + 'token' => LoginForm::getCreateaccountToken(), + ) ); + } + + /** + * Make sure requests with existing users are invalid. + * @expectedException UsageException + */ + public function testExistingUser() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Apitestsysop', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + 'email' => 'test@domain.test', + ) ); + } + + /** + * Make sure requests with invalid emails are invalid. + * @expectedException UsageException + */ + public function testInvalidEmail() { + $this->doApiRequest( array( + 'action' => 'createaccount', + 'name' => 'Test User', + 'token' => LoginForm::getCreateaccountToken(), + 'password' => 'password', + 'email' => 'invalid', + ) ); + } +} diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php new file mode 100644 index 00000000..3179a452 --- /dev/null +++ b/tests/phpunit/includes/api/ApiEditPageTest.php @@ -0,0 +1,496 @@ +<?php + +/** + * Tests for MediaWiki api.php?action=edit. + * + * @author Daniel Kinzler + * + * @group API + * @group Database + * @group medium + * + * @covers ApiEditPage + */ +class ApiEditPageTest extends ApiTestCase { + + protected function setUp() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::setUp(); + + $this->setMwGlobals( array( + 'wgExtraNamespaces' => $wgExtraNamespaces, + 'wgNamespaceContentModels' => $wgNamespaceContentModels, + 'wgContentHandlers' => $wgContentHandlers, + 'wgContLang' => $wgContLang, + ) ); + + $wgExtraNamespaces[12312] = 'Dummy'; + $wgExtraNamespaces[12313] = 'Dummy_talk'; + + $wgNamespaceContentModels[12312] = "testing"; + $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting'; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + + $this->doLogin(); + } + + protected function tearDown() { + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + parent::tearDown(); + } + + public function testEdit() { + $name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext + + // -- test new page -------------------------------------------- + $apiResult = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ) ); + $apiResult = $apiResult[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // -- test existing page, no change ---------------------------- + $data = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ) ); + + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayHasKey( 'nochange', $data[0]['edit'] ); + + // -- test existing page, with change -------------------------- + $data = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => 'different text' + ) ); + + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] ); + + $this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] ); + $this->assertArrayHasKey( 'newrevid', $data[0]['edit'] ); + $this->assertNotEquals( + $data[0]['edit']['newrevid'], + $data[0]['edit']['oldrevid'], + "revision id should change after edit" + ); + } + + public function testNonTextEdit() { + $name = 'Dummy:ApiEditPageTest_testNonTextEdit'; + $data = serialize( 'some bla bla text' ); + + // -- test new page -------------------------------------------- + $apiResult = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => $data, ) ); + $apiResult = $apiResult[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertEquals( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // validate resulting revision + $page = WikiPage::factory( Title::newFromText( $name ) ); + $this->assertEquals( "testing", $page->getContentModel() ); + $this->assertEquals( $data, $page->getContent()->serialize() ); + } + + /** + * @return array + */ + public static function provideEditAppend() { + return array( + array( #0: append + 'foo', 'append', 'bar', "foobar" + ), + array( #1: prepend + 'foo', 'prepend', 'bar', "barfoo" + ), + array( #2: append to empty page + '', 'append', 'foo', "foo" + ), + array( #3: prepend to empty page + '', 'prepend', 'foo', "foo" + ), + array( #4: append to non-existing page + null, 'append', 'foo', "foo" + ), + array( #5: prepend to non-existing page + null, 'prepend', 'foo', "foo" + ), + ); + } + + /** + * @dataProvider provideEditAppend + */ + public function testEditAppend( $text, $op, $append, $expected ) { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditAppend_$count"; + + // -- create page (or not) ----------------------------------------- + if ( $text !== null ) { + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'text' => $text, ) ); + + $this->assertEquals( 'Success', $re['edit']['result'] ); // sanity + } + + // -- try append/prepend -------------------------------------------- + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + $op . 'text' => $append, ) ); + + $this->assertEquals( 'Success', $re['edit']['result'] ); + + // -- validate ----------------------------------------------------- + $page = new WikiPage( Title::newFromText( $name ) ); + $content = $page->getContent(); + $this->assertNotNull( $content, 'Page should have been created' ); + + $text = $content->getNativeData(); + + $this->assertEquals( $expected, $text ); + } + + /** + * Test editing of sections + */ + public function testEditSection() { + $name = 'Help:ApiEditPageTest_testEditSection'; + $page = WikiPage::factory( Title::newFromText( $name ) ); + $text = "==section 1==\ncontent 1\n==section 2==\ncontent2"; + // Preload the page with some text + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), 'summary' ); + + list( $re ) = $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => '1', + 'text' => "==section 1==\nnew content 1", + ) ); + $this->assertEquals( 'Success', $re['edit']['result'] ); + $newtext = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertEquals( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext ); + + // Test that we raise a 'nosuchsection' error + try { + $this->doApiRequestWithToken( array( + 'action' => 'edit', + 'title' => $name, + 'section' => '9999', + 'text' => 'text', + ) ); + $this->fail( "Should have raised a UsageException" ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nosuchsection', $e->getCodeString() ); + } + } + + /** + * Test action=edit§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 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiLogin + */ +class ApiLoginTest extends ApiTestCase { + + /** + * Test result of attempted login with an empty username + */ + public function testApiLoginNoName() { + $data = $this->doApiRequest( array( 'action' => 'login', + 'lgname' => '', 'lgpassword' => self::$users['sysop']->password, + ) ); + $this->assertEquals( 'NoName', $data[0]['login']['result'] ); + } + + public function testApiLoginBadPass() { + global $wgServer; + + $user = self::$users['sysop']; + $user->user->logOut(); + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $ret = $this->doApiRequest( array( + "action" => "login", + "lgname" => $user->username, + "lgpassword" => "bad", + ) ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + array( + "action" => "login", + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => "badnowayinhell", + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( "WrongPass", $a ); + } + + public function testApiLoginGoodPass() { + global $wgServer; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $user = self::$users['sysop']; + $user->user->logOut(); + + $ret = $this->doApiRequest( array( + "action" => "login", + "lgname" => $user->username, + "lgpassword" => $user->password, + ) + ); + + $result = $ret[0]; + $this->assertNotInternalType( "bool", $result ); + $this->assertNotInternalType( "null", $result["login"] ); + + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + array( + "action" => "login", + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => $user->password, + ), + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( "Success", $a ); + } + + /** + * @group Broken + */ + public function testApiLoginGotCookie() { + $this->markTestIncomplete( "The server can't do external HTTP requests, " + . "and the internal one won't give cookies" ); + + global $wgServer, $wgScriptPath; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $user = self::$users['sysop']; + + $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml", + array( "method" => "POST", + "postData" => array( + "lgname" => $user->username, + "lgpassword" => $user->password + ) + ) + ); + $req->execute(); + + libxml_use_internal_errors( true ); + $sxe = simplexml_load_string( $req->getContent() ); + $this->assertNotInternalType( "bool", $sxe ); + $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) ); + $this->assertNotInternalType( "null", $sxe->login[0] ); + + $a = $sxe->login[0]->attributes()->result[0]; + $this->assertEquals( ' result="NeedToken"', $a->asXML() ); + $token = (string)$sxe->login[0]->attributes()->token; + + $req->setData( array( + "lgtoken" => $token, + "lgname" => $user->username, + "lgpassword" => $user->password ) ); + $req->execute(); + + $cj = $req->getCookieJar(); + $serverName = parse_url( $wgServer, PHP_URL_HOST ); + $this->assertNotEquals( false, $serverName ); + $serializedCookie = $cj->serializeToHttpRequest( $wgScriptPath, $serverName ); + $this->assertNotEquals( '', $serializedCookie ); + $this->assertRegexp( + '/_session=[^;]*; .*UserID=[0-9]*; .*UserName=' . $user->userName . '; .*Token=/', + $serializedCookie + ); + } + + public function testRunLogin() { + $sysopUser = self::$users['sysop']; + $data = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => $sysopUser->username, + 'lgpassword' => $sysopUser->password ) ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "NeedToken", $data[0]['login']['result'] ); + $token = $data[0]['login']['token']; + + $data = $this->doApiRequest( array( + 'action' => 'login', + "lgtoken" => $token, + "lgname" => $sysopUser->username, + "lgpassword" => $sysopUser->password ), $data[2] ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "Success", $data[0]['login']['result'] ); + $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] ); + } + +} diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php new file mode 100644 index 00000000..780cf9ed --- /dev/null +++ b/tests/phpunit/includes/api/ApiMainTest.php @@ -0,0 +1,72 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiMain + */ +class ApiMainTest extends ApiTestCase { + + /** + * Test that the API will accept a FauxRequest and execute. The help action + * (default) throws a UsageException. Just validate we're getting proper XML + * + * @expectedException UsageException + */ + public function testApi() { + $api = new ApiMain( + new FauxRequest( array( 'action' => 'help', 'format' => 'xml' ) ) + ); + $api->execute(); + $api->getPrinter()->setBufferResult( true ); + $api->printResult( false ); + $resp = $api->getPrinter()->getBuffer(); + + libxml_use_internal_errors( true ); + $sxe = simplexml_load_string( $resp ); + $this->assertNotInternalType( "bool", $sxe ); + $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) ); + } + + public static function provideAssert() { + $anon = new User(); + $bot = new User(); + $bot->setName( 'Bot' ); + $bot->addToDatabase(); + $bot->addGroup( 'bot' ); + $user = new User(); + $user->setName( 'User' ); + $user->addToDatabase(); + return array( + array( $anon, 'user', 'assertuserfailed' ), + array( $user, 'user', false ), + array( $user, 'bot', 'assertbotfailed' ), + array( $bot, 'user', false ), + array( $bot, 'bot', false ), + ); + } + + /** + * Tests the assert={user|bot} functionality + * + * @covers ApiMain::checkAsserts + * @dataProvider provideAssert + * @param User $user + * @param string $assert + * @param string|bool $error False if no error expected + */ + public function testAssert( $user, $assert, $error ) { + try { + $this->doApiRequest( array( + 'action' => 'query', + 'assert' => $assert, + ), null, null, $user ); + $this->assertFalse( $error ); // That no error was expected + } catch ( UsageException $e ) { + $this->assertEquals( $e->getCodeString(), $error ); + } + } + +} diff --git a/tests/phpunit/includes/api/ApiModuleManagerTest.php b/tests/phpunit/includes/api/ApiModuleManagerTest.php new file mode 100644 index 00000000..dab81e16 --- /dev/null +++ b/tests/phpunit/includes/api/ApiModuleManagerTest.php @@ -0,0 +1,318 @@ +<?php + +/** + * @covers ApiModuleManager + * + * @group API + * @group Database + * @group medium + */ +class ApiModuleManagerTest extends MediaWikiTestCase { + + private function getModuleManager() { + $request = new FauxRequest(); + $main = new ApiMain( $request ); + return new ApiModuleManager( $main ); + } + + public function newApiLogin( $main, $action ) { + return new ApiLogin( $main, $action ); + } + + public function addModuleProvider() { + return array( + 'plain class' => array( + 'login', + 'action', + 'ApiLogin', + null, + ), + + 'with factory' => array( + 'login', + 'action', + 'ApiLogin', + array( $this, 'newApiLogin' ), + ), + + 'with closure' => array( + 'logout', + 'action', + 'ApiLogout', + function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ); + } + + /** + * @dataProvider addModuleProvider + */ + public function testAddModule( $name, $group, $class, $factory = null ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModule( $name, $group, $class, $factory ); + + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + public function addModulesProvider() { + return array( + 'empty' => array( + array(), + 'action', + ), + + 'simple' => array( + array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ), + 'action', + ), + + 'with factories' => array( + array( + 'login' => array( + 'class' => 'ApiLogin', + 'factory' => array( $this, 'newApiLogin' ), + ), + 'logout' => array( + 'class' => 'ApiLogout', + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ), + 'action', + ), + ); + } + + /** + * @dataProvider addModulesProvider + */ + public function testAddModules( array $modules, $group ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, $group ); + + foreach ( array_keys( $modules ) as $name ) { + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + $this->assertTrue( true ); // Don't mark the test as risky if $modules is empty + } + + public function getModuleProvider() { + $modules = array( + 'feedrecentchanges' => 'ApiFeedRecentChanges', + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'login' => array( + 'class' => 'ApiLogin', + 'factory' => array( $this, 'newApiLogin' ), + ), + 'logout' => array( + 'class' => 'ApiLogout', + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ), + ); + + return array( + 'legacy entry' => array( + $modules, + 'feedrecentchanges', + 'ApiFeedRecentChanges', + ), + + 'just a class' => array( + $modules, + 'feedcontributions', + 'ApiFeedContributions', + ), + + 'with factory' => array( + $modules, + 'login', + 'ApiLogin', + ), + + 'with closure' => array( + $modules, + 'logout', + 'ApiLogout', + ), + ); + } + + /** + * @covers ApiModuleManager::getModule + * @dataProvider getModuleProvider + */ + public function testGetModule( $modules, $name, $expectedClass ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + // should return the right module + $module1 = $moduleManager->getModule( $name, null, false ); + $this->assertInstanceOf( $expectedClass, $module1 ); + + // should pass group check (with caching disabled) + $module2 = $moduleManager->getModule( $name, 'test', true ); + $this->assertNotNull( $module2 ); + + // should use cached instance + $module3 = $moduleManager->getModule( $name, null, false ); + $this->assertSame( $module1, $module3 ); + + // should not use cached instance if caching is disabled + $module4 = $moduleManager->getModule( $name, null, true ); + $this->assertNotSame( $module1, $module4 ); + } + + /** + * @covers ApiModuleManager::getModule + */ + public function testGetModule_null() { + $modules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + $this->assertNull( $moduleManager->getModule( 'quux' ), 'unknown name' ); + $this->assertNull( $moduleManager->getModule( 'login', 'bla' ), 'wrong group' ); + } + + /** + * @covers ApiModuleManager::getNames + */ + public function testGetNames() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNames = $moduleManager->getNames( 'foo' ); + $this->assertArrayEquals( array_keys( $fooModules ), $fooNames ); + + $allNames = $moduleManager->getNames(); + $allModules = array_merge( $fooModules, $barModules ); + $this->assertArrayEquals( array_keys( $allModules ), $allNames ); + } + + /** + * @covers ApiModuleManager::getNamesWithClasses + */ + public function testGetNamesWithClasses() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNamesWithClasses = $moduleManager->getNamesWithClasses( 'foo' ); + $this->assertArrayEquals( $fooModules, $fooNamesWithClasses ); + + $allNamesWithClasses = $moduleManager->getNamesWithClasses(); + $allModules = array_merge( $fooModules, array( + 'feedcontributions' => 'ApiFeedContributions', + 'feedrecentchanges' => 'ApiFeedRecentChanges', + ) ); + $this->assertArrayEquals( $allModules, $allNamesWithClasses ); + } + + /** + * @covers ApiModuleManager::getModuleGroup + */ + public function testGetModuleGroup() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( 'foo', $moduleManager->getModuleGroup( 'login' ) ); + $this->assertEquals( 'bar', $moduleManager->getModuleGroup( 'feedrecentchanges' ) ); + $this->assertNull( $moduleManager->getModuleGroup( 'quux' ) ); + } + + /** + * @covers ApiModuleManager::getGroups + */ + public function testGetGroups() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $groups = $moduleManager->getGroups(); + $this->assertArrayEquals( array( 'foo', 'bar' ), $groups ); + } + + /** + * @covers ApiModuleManager::getClassName + */ + public function testGetClassName() { + $fooModules = array( + 'login' => 'ApiLogin', + 'logout' => 'ApiLogout', + ); + + $barModules = array( + 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ), + 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ), + ); + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( 'ApiLogin', $moduleManager->getClassName( 'login' ) ); + $this->assertEquals( 'ApiLogout', $moduleManager->getClassName( 'logout' ) ); + $this->assertEquals( 'ApiFeedContributions', $moduleManager->getClassName( 'feedcontributions' ) ); + $this->assertEquals( 'ApiFeedRecentChanges', $moduleManager->getClassName( 'feedrecentchanges' ) ); + $this->assertFalse( $moduleManager->getClassName( 'nonexistentmodule' ) ); + } + + +} diff --git a/tests/phpunit/includes/api/ApiOptionsTest.php b/tests/phpunit/includes/api/ApiOptionsTest.php new file mode 100644 index 00000000..5f955bbc --- /dev/null +++ b/tests/phpunit/includes/api/ApiOptionsTest.php @@ -0,0 +1,459 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiOptions + */ +class ApiOptionsTest extends MediaWikiLangTestCase { + + /** @var PHPUnit_Framework_MockObject_MockObject */ + private $mUserMock; + /** @var ApiOptions */ + private $mTested; + private $mSession; + /** @var DerivativeContext */ + private $mContext; + + private $mOldGetPreferencesHooks; + + private static $Success = array( 'options' => 'success' ); + + protected function setUp() { + parent::setUp(); + + $this->mUserMock = $this->getMockBuilder( 'User' ) + ->disableOriginalConstructor() + ->getMock(); + + // Set up groups and rights + $this->mUserMock->expects( $this->any() ) + ->method( 'getEffectiveGroups' )->will( $this->returnValue( array( '*', 'user' ) ) ); + $this->mUserMock->expects( $this->any() ) + ->method( 'isAllowed' )->will( $this->returnValue( true ) ); + + // Set up callback for User::getOptionKinds + $this->mUserMock->expects( $this->any() ) + ->method( 'getOptionKinds' )->will( $this->returnCallback( array( $this, 'getOptionKinds' ) ) ); + + // Create a new context + $this->mContext = new DerivativeContext( new RequestContext() ); + $this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) ); + $this->mContext->setUser( $this->mUserMock ); + + $main = new ApiMain( $this->mContext ); + + // Empty session + $this->mSession = array(); + + $this->mTested = new ApiOptions( $main, 'options' ); + + global $wgHooks; + if ( !isset( $wgHooks['GetPreferences'] ) ) { + $wgHooks['GetPreferences'] = array(); + } + $this->mOldGetPreferencesHooks = $wgHooks['GetPreferences']; + $wgHooks['GetPreferences'][] = array( $this, 'hookGetPreferences' ); + } + + protected function tearDown() { + global $wgHooks; + + $wgHooks['GetPreferences'] = $this->mOldGetPreferencesHooks; + $this->mOldGetPreferencesHooks = false; + + parent::tearDown(); + } + + public function hookGetPreferences( $user, &$preferences ) { + $preferences = array(); + + foreach ( array( 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ) as $k ) { + $preferences[$k] = array( + 'type' => 'text', + 'section' => 'test', + 'label' => ' ', + ); + } + + $preferences['testmultiselect'] = array( + 'type' => 'multiselect', + 'options' => array( + 'Test' => array( + '<span dir="auto">Some HTML here for option 1</span>' => 'opt1', + '<span dir="auto">Some HTML here for option 2</span>' => 'opt2', + '<span dir="auto">Some HTML here for option 3</span>' => 'opt3', + '<span dir="auto">Some HTML here for option 4</span>' => 'opt4', + ), + ), + 'section' => 'test', + 'label' => ' ', + 'prefix' => 'testmultiselect-', + 'default' => array(), + ); + + return true; + } + + /** + * @param IContextSource $context + * @param array|null $options + * + * @return array + */ + public function getOptionKinds( IContextSource $context, $options = null ) { + // Match with above. + $kinds = array( + 'name' => 'registered', + 'willBeNull' => 'registered', + 'willBeEmpty' => 'registered', + 'willBeHappy' => 'registered', + 'testmultiselect-opt1' => 'registered-multiselect', + 'testmultiselect-opt2' => 'registered-multiselect', + 'testmultiselect-opt3' => 'registered-multiselect', + 'testmultiselect-opt4' => 'registered-multiselect', + 'special' => 'special', + ); + + if ( $options === null ) { + return $kinds; + } + + $mapping = array(); + foreach ( $options as $key => $value ) { + if ( isset( $kinds[$key] ) ) { + $mapping[$key] = $kinds[$key]; + } elseif ( substr( $key, 0, 7 ) === 'userjs-' ) { + $mapping[$key] = 'userjs'; + } else { + $mapping[$key] = 'unused'; + } + } + + return $mapping; + } + + private function getSampleRequest( $custom = array() ) { + $request = array( + 'token' => '123ABC', + 'change' => null, + 'optionname' => null, + 'optionvalue' => null, + ); + + return array_merge( $request, $custom ); + } + + private function executeQuery( $request ) { + $this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) ); + $this->mTested->execute(); + + return $this->mTested->getResult()->getData(); + } + + /** + * @expectedException UsageException + */ + public function testNoToken() { + $request = $this->getSampleRequest( array( 'token' => null ) ); + + $this->executeQuery( $request ); + } + + public function testAnon() { + $this->mUserMock->expects( $this->once() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'notloggedin', $e->getCodeString() ); + $this->assertEquals( 'Anonymous users cannot change preferences', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testNoOptionname() { + try { + $request = $this->getSampleRequest( array( 'optionvalue' => '1' ) ); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nooptionname', $e->getCodeString() ); + $this->assertEquals( 'The optionname parameter must be set', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testNoChanges() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( UsageException $e ) { + $this->assertEquals( 'nochanges', $e->getCodeString() ); + $this->assertEquals( 'No changes were requested', $e->getMessage() ); + + return; + } + $this->fail( "UsageException was not thrown" ); + } + + public function testReset() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( array( 'all' ) ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'reset' => '' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetKinds() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( array( 'registered' ) ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'reset' => '', 'resetkinds' => 'registered' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionWithValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'optionname' => 'name', 'optionvalue' => 'value' ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionResetValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( 'optionname' => 'name' ) ); + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testChange() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 2 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) ); + + $this->mUserMock->expects( $this->at( 7 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 8 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetChangeOption() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'getOptions' ); + + $this->mUserMock->expects( $this->at( 7 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $args = array( + 'reset' => '', + 'change' => 'willBeHappy=Happy', + 'optionname' => 'name', + 'optionvalue' => 'value' + ); + + $response = $this->executeQuery( $this->getSampleRequest( $args ) ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testMultiSelect() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 3 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) ); + + $this->mUserMock->expects( $this->at( 4 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->at( 5 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) ); + + $this->mUserMock->expects( $this->at( 6 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|' + . 'testmultiselect-opt3=|testmultiselect-opt4=0' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testSpecialOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'special=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( array( + 'options' => 'success', + 'warnings' => array( + 'options' => array( + '*' => "Validation error for 'special': cannot be set by this module" + ) + ) + ), $response ); + } + + public function testUnknownOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'unknownOption=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( array( + 'options' => 'success', + 'warnings' => array( + 'options' => array( + '*' => "Validation error for 'unknownOption': not a valid preference" + ) + ) + ), $response ); + } + + public function testUserjsOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->at( 3 ) ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( array( + 'change' => 'userjs-option=1' + ) ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } +} diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php new file mode 100644 index 00000000..d038a4f5 --- /dev/null +++ b/tests/phpunit/includes/api/ApiParseTest.php @@ -0,0 +1,35 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiParse + */ +class ApiParseTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + public function testParseNonexistentPage() { + $somePage = mt_rand(); + + try { + $this->doApiRequest( array( + 'action' => 'parse', + 'page' => $somePage ) ); + + $this->fail( "API did not return an error when parsing a nonexistent page" ); + } catch ( UsageException $ex ) { + $this->assertEquals( + 'missingtitle', + $ex->getCodeString(), + "Parse request for nonexistent page must give 'missingtitle' error: " + . var_export( $ex->getMessageArray(), true ) + ); + } + } +} diff --git a/tests/phpunit/includes/api/ApiPurgeTest.php b/tests/phpunit/includes/api/ApiPurgeTest.php new file mode 100644 index 00000000..7fce134a --- /dev/null +++ b/tests/phpunit/includes/api/ApiPurgeTest.php @@ -0,0 +1,45 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiPurge + */ +class ApiPurgeTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + /** + * @group Broken + */ + public function testPurgeMainPage() { + if ( !Title::newFromText( 'UTPage' )->exists() ) { + $this->markTestIncomplete( "The article [[UTPage]] does not exist" ); + } + + $somePage = mt_rand(); + + $data = $this->doApiRequest( array( + 'action' => 'purge', + 'titles' => 'UTPage|' . $somePage . '|%5D' ) ); + + $this->assertArrayHasKey( 'purge', $data[0], + "Must receive a 'purge' result from API" ); + + $this->assertEquals( + 3, + count( $data[0]['purge'] ), + "Purge request for three articles should give back three results received: " + . var_export( $data[0]['purge'], true ) ); + + $pages = array( 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' ); + foreach ( $data[0]['purge'] as $v ) { + $this->assertArrayHasKey( $pages[$v['title']], $v ); + } + } +} diff --git a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php new file mode 100644 index 00000000..124988f3 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php @@ -0,0 +1,34 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + */ +class ApiQueryAllPagesTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + /** + * @todo give this test a real name explaining what is being tested here + */ + public function testBug25702() { + $title = Title::newFromText( 'Category:Template:xyz' ); + $page = WikiPage::factory( $title ); + $page->doEdit( 'Some text', 'inserting content' ); + + $result = $this->doApiRequest( array( + 'action' => 'query', + 'list' => 'allpages', + 'apnamespace' => NS_CATEGORY, + 'apprefix' => 'Template:x' ) ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'allpages', $result[0]['query'] ); + $this->assertNotEquals( 0, count( $result[0]['query']['allpages'] ), + 'allpages list does not contain page Category:Template:xyz' ); + } +} diff --git a/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php new file mode 100644 index 00000000..b03836eb --- /dev/null +++ b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php @@ -0,0 +1,114 @@ +<?php + +/** + * Tests for action=revisiondelete + * @covers APIRevisionDelete + * @group API + * @group medium + * @group Database + */ +class ApiRevisionDeleteTest extends ApiTestCase { + + public static $page = 'Help:ApiRevDel_test'; + public $revs = array(); + + protected function setUp() { + // Needs to be before setup since this gets cached + $this->mergeMwGlobalArrayValue( 'wgGroupPermissions', array( 'sysop' => array( 'deleterevision' => true ) ) ); + parent::setUp(); + // Make a few edits for us to play with + for ( $i = 1; $i <= 5; $i++ ) { + self::editPage( self::$page, MWCryptRand::generateHex( 10 ), 'summary' ); + $this->revs[] = Title::newFromText( self::$page )->getLatestRevID( Title::GAID_FOR_UPDATE ); + } + + } + + public function testHidingRevisions() { + $user = self::$users['sysop']->user; + $revid = array_shift( $this->revs ); + $out = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + // Check the output + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + $this->assertArrayHasKey( 'userhidden', $item ); + $this->assertArrayHasKey( 'commenthidden', $item ); + $this->assertArrayHasKey( 'texthidden', $item ); + $this->assertEquals( $item['id'], $revid ); + + // Now check that that revision was actually hidden + $rev = Revision::newFromId( $revid ); + $this->assertEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + + // Now test unhiding! + $out2 = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + + // Check the output + $out2 = $out2[0]['revisiondelete']; + $this->assertEquals( $out2['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out2 ); + $item = $out2['items'][0]; + + $this->assertArrayNotHasKey( 'userhidden', $item ); + $this->assertArrayNotHasKey( 'commenthidden', $item ); + $this->assertArrayNotHasKey( 'texthidden', $item ); + + $this->assertEquals( $item['id'], $revid ); + + $rev = Revision::newFromId( $revid ); + $this->assertNotEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertNotEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertNotEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + } + + public function testUnhidingOutput() { + $user = self::$users['sysop']->user; + $revid = array_shift( $this->revs ); + // Hide revisions + $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ) ); + + $out = $this->doApiRequest( array( + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'comment', + 'token' => $user->getEditToken(), + ) ); + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + // Check it has userhidden & texthidden keys + // but no commenthidden key + $this->assertArrayHasKey( 'userhidden', $item ); + $this->assertArrayNotHasKey( 'commenthidden', $item ); + $this->assertArrayHasKey( 'texthidden', $item ); + $this->assertEquals( $item['id'], $revid ); + } +} diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php new file mode 100644 index 00000000..cd141947 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestCase.php @@ -0,0 +1,196 @@ +<?php + +abstract class ApiTestCase extends MediaWikiLangTestCase { + protected static $apiUrl; + + /** + * @var ApiTestContext + */ + protected $apiContext; + + protected function setUp() { + global $wgServer; + + parent::setUp(); + self::$apiUrl = $wgServer . wfScript( 'api' ); + + ApiQueryInfo::resetTokenCache(); // tokens are invalid because we cleared the session + + self::$users = array( + 'sysop' => new TestUser( + 'Apitestsysop', + 'Api Test Sysop', + 'api_test_sysop@example.com', + array( 'sysop' ) + ), + 'uploader' => new TestUser( + 'Apitestuser', + 'Api Test User', + 'api_test_user@example.com', + array() + ) + ); + + $this->setMwGlobals( array( + 'wgMemc' => new EmptyBagOStuff(), + 'wgAuth' => new StubObject( 'wgAuth', 'AuthPlugin' ), + 'wgRequest' => new FauxRequest( array() ), + 'wgUser' => self::$users['sysop']->user, + ) ); + + $this->apiContext = new ApiTestContext(); + } + + /** + * Edits or creates a page/revision + * @param string $pageName Page title + * @param string $text Content of the page + * @param string $summary Optional summary string for the revision + * @param int $defaultNs Optional namespace id + * @return array Array as returned by WikiPage::doEditContent() + */ + protected function editPage( $pageName, $text, $summary = '', $defaultNs = NS_MAIN ) { + $title = Title::newFromText( $pageName, $defaultNs ); + $page = WikiPage::factory( $title ); + + return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary ); + } + + /** + * Does the API request and returns the result. + * + * The returned value is an array containing + * - the result data (array) + * - the request (WebRequest) + * - the session data of the request (array) + * - if $appendModule is true, the Api module $module + * + * @param array $params + * @param array|null $session + * @param bool $appendModule + * @param User|null $user + * + * @return array + */ + protected function doApiRequest( array $params, array $session = null, + $appendModule = false, User $user = null + ) { + global $wgRequest, $wgUser; + + if ( is_null( $session ) ) { + // re-use existing global session by default + $session = $wgRequest->getSessionArray(); + } + + // set up global environment + if ( $user ) { + $wgUser = $user; + } + + $wgRequest = new FauxRequest( $params, true, $session ); + RequestContext::getMain()->setRequest( $wgRequest ); + + // set up local environment + $context = $this->apiContext->newTestContext( $wgRequest, $wgUser ); + + $module = new ApiMain( $context, true ); + + // run it! + $module->execute(); + + // construct result + $results = array( + $module->getResultData(), + $context->getRequest(), + $context->getRequest()->getSessionArray() + ); + + if ( $appendModule ) { + $results[] = $module; + } + + return $results; + } + + /** + * Add an edit token to the API request + * This is cheating a bit -- we grab a token in the correct format and then + * add it to the pseudo-session and to the request, without actually + * requesting a "real" edit token. + * + * @param array $params Key-value API params + * @param array|null $session Session array + * @param User|null $user A User object for the context + * @return array Result of the API call + * @throws Exception In case wsToken is not set in the session + */ + protected function doApiRequestWithToken( array $params, array $session = null, + User $user = null + ) { + global $wgRequest; + + if ( $session === null ) { + $session = $wgRequest->getSessionArray(); + } + + if ( isset( $session['wsToken'] ) && $session['wsToken'] ) { + // add edit token to fake session + $session['wsEditToken'] = $session['wsToken']; + // add token to request parameters + $params['token'] = md5( $session['wsToken'] ) . User::EDIT_TOKEN_SUFFIX; + + return $this->doApiRequest( $params, $session, false, $user ); + } else { + throw new Exception( "Session token not available" ); + } + } + + protected function doLogin( $user = 'sysop' ) { + if ( !array_key_exists( $user, self::$users ) ) { + throw new MWException( "Can not log in to undefined user $user" ); + } + + $data = $this->doApiRequest( array( + 'action' => 'login', + 'lgname' => self::$users[$user]->username, + 'lgpassword' => self::$users[$user]->password ) ); + + $token = $data[0]['login']['token']; + + $data = $this->doApiRequest( + array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => self::$users[$user]->username, + 'lgpassword' => self::$users[$user]->password, + ), + $data[2] + ); + + return $data; + } + + protected function getTokenList( $user, $session = null ) { + $data = $this->doApiRequest( array( + 'action' => 'tokens', + 'type' => 'edit|delete|protect|move|block|unblock|watch' + ), $session, false, $user->user ); + + if ( !array_key_exists( 'tokens', $data[0] ) ) { + throw new MWException( 'Api failed to return a token list' ); + } + + return $data[0]['tokens']; + } + + public function testApiTestGroup() { + $groups = PHPUnit_Util_Test::getGroups( get_class( $this ) ); + $constraint = PHPUnit_Framework_Assert::logicalOr( + $this->contains( 'medium' ), + $this->contains( 'large' ) + ); + $this->assertThat( $groups, $constraint, + 'ApiTestCase::setUp can be slow, tests must be "medium" or "large"' + ); + } +} diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php new file mode 100644 index 00000000..7e513394 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php @@ -0,0 +1,171 @@ +<?php + +/** + * * Abstract class to support upload tests + */ + +abstract class ApiTestCaseUpload extends ApiTestCase { + /** + * Fixture -- run before every test + */ + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableUploads' => true, + 'wgEnableAPI' => true, + ) ); + + wfSetupSession(); + + $this->clearFakeUploads(); + } + + protected function tearDown() { + $this->clearTempUpload(); + + parent::tearDown(); + } + + /** + * Helper function -- remove files and associated articles by Title + * + * @param Title $title Title to be removed + * + * @return bool + */ + public function deleteFileByTitle( $title ) { + if ( $title->exists() ) { + $file = wfFindFile( $title, array( 'ignoreRedirect' => true ) ); + $noOldArchive = ""; // yes this really needs to be set this way + $comment = "removing for test"; + $restrictDeletedVersions = false; + $status = FileDeleteForm::doDelete( + $title, + $file, + $noOldArchive, + $comment, + $restrictDeletedVersions + ); + + if ( !$status->isGood() ) { + return false; + } + + $page = WikiPage::factory( $title ); + $page->doDeleteArticle( "removing for test" ); + + // see if it now doesn't exist; reload + $title = Title::newFromText( $title->getText(), NS_FILE ); + } + + return !( $title && $title instanceof Title && $title->exists() ); + } + + /** + * Helper function -- remove files and associated articles with a particular filename + * + * @param string $fileName Filename to be removed + * + * @return bool + */ + public function deleteFileByFileName( $fileName ) { + return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) ); + } + + /** + * Helper function -- given a file on the filesystem, find matching + * content in the db (and associated articles) and remove them. + * + * @param string $filePath Path to file on the filesystem + * + * @return bool + */ + public function deleteFileByContent( $filePath ) { + $hash = FSFile::getSha1Base36FromPath( $filePath ); + $dupes = RepoGroup::singleton()->findBySha1( $hash ); + $success = true; + foreach ( $dupes as $dupe ) { + $success &= $this->deleteFileByTitle( $dupe->getTitle() ); + } + + return $success; + } + + /** + * Fake an upload by dumping the file into temp space, and adding info to $_FILES. + * (This is what PHP would normally do). + * + * @param string $fieldName Name this would have in the upload form + * @param string $fileName Name to title this + * @param string $type MIME type + * @param string $filePath Path where to find file contents + * + * @throws Exception + * @return bool + */ + function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) { + $tmpName = tempnam( wfTempDir(), "" ); + if ( !file_exists( $filePath ) ) { + throw new Exception( "$filePath doesn't exist!" ); + } + + if ( !copy( $filePath, $tmpName ) ) { + throw new Exception( "couldn't copy $filePath to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = array( + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ); + + return true; + } + + function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) { + $tmpName = tempnam( wfTempDir(), "" ); + // copy the chunk data to temp location: + if ( !file_put_contents( $tmpName, $chunkData ) ) { + throw new Exception( "couldn't copy chunk data to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = array( + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ); + } + + function clearTempUpload() { + if ( isset( $_FILES['file']['tmp_name'] ) ) { + $tmp = $_FILES['file']['tmp_name']; + if ( file_exists( $tmp ) ) { + unlink( $tmp ); + } + } + } + + /** + * Remove traces of previous fake uploads + */ + function clearFakeUploads() { + $_FILES = array(); + } +} diff --git a/tests/phpunit/includes/api/ApiTestContext.php b/tests/phpunit/includes/api/ApiTestContext.php new file mode 100644 index 00000000..17dad1fa --- /dev/null +++ b/tests/phpunit/includes/api/ApiTestContext.php @@ -0,0 +1,21 @@ +<?php + +class ApiTestContext extends RequestContext { + + /** + * Returns a DerivativeContext with the request variables in place + * + * @param WebRequest $request WebRequest request object including parameters and session + * @param User|null $user User or null + * @return DerivativeContext + */ + public function newTestContext( WebRequest $request, User $user = null ) { + $context = new DerivativeContext( $this ); + $context->setRequest( $request ); + if ( $user !== null ) { + $context->setUser( $user ); + } + + return $context; + } +} diff --git a/tests/phpunit/includes/api/ApiTokensTest.php b/tests/phpunit/includes/api/ApiTokensTest.php new file mode 100644 index 00000000..fbe97893 --- /dev/null +++ b/tests/phpunit/includes/api/ApiTokensTest.php @@ -0,0 +1,40 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiTokens + */ +class ApiTokensTest extends ApiTestCase { + + public function testGettingToken() { + foreach ( self::$users as $user ) { + $this->runTokenTest( $user ); + } + } + + protected function runTokenTest( $user ) { + $tokens = $this->getTokenList( $user ); + + $rights = $user->user->getRights(); + + $this->assertArrayHasKey( 'edittoken', $tokens ); + $this->assertArrayHasKey( 'movetoken', $tokens ); + + if ( isset( $rights['delete'] ) ) { + $this->assertArrayHasKey( 'deletetoken', $tokens ); + } + + if ( isset( $rights['block'] ) ) { + $this->assertArrayHasKey( 'blocktoken', $tokens ); + $this->assertArrayHasKey( 'unblocktoken', $tokens ); + } + + if ( isset( $rights['protect'] ) ) { + $this->assertArrayHasKey( 'protecttoken', $tokens ); + } + } + +} diff --git a/tests/phpunit/includes/api/ApiUnblockTest.php b/tests/phpunit/includes/api/ApiUnblockTest.php new file mode 100644 index 00000000..2c2370a8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiUnblockTest.php @@ -0,0 +1,31 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiUnblock + */ +class ApiUnblockTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + /** + * @expectedException UsageException + */ + public function testWithNoToken( ) { + $this->doApiRequest( + array( + 'action' => 'unblock', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + ), + null, + false, + self::$users['sysop']->user + ); + } +} diff --git a/tests/phpunit/includes/api/ApiUploadTest.php b/tests/phpunit/includes/api/ApiUploadTest.php new file mode 100644 index 00000000..8ea761f8 --- /dev/null +++ b/tests/phpunit/includes/api/ApiUploadTest.php @@ -0,0 +1,572 @@ +<?php +/** + * @group API + * @group Database + * @group medium + */ + +/** + * n.b. Ensure that you can write to the images/ directory as the + * user that will run tests. + */ + +// Note for reviewers: this intentionally duplicates functionality already in +// "ApiSetup" and so on. This framework works better IMO and has less +// strangeness (such as test cases inheriting from "ApiSetup"...) (and in the +// case of the other Upload tests, this flat out just actually works... ) + +// @todo Port the other Upload tests, and other API tests to this framework + +require_once 'ApiTestCaseUpload.php'; + +/** + * @group Database + * @group Broken + * Broken test, reports false errors from time to time. + * See https://bugzilla.wikimedia.org/26169 + * + * This is pretty sucky... needs to be prettified. + */ +class ApiUploadTest extends ApiTestCaseUpload { + /** + * Testing login + * XXX this is a funny way of getting session context + */ + public function testLogin() { + $user = self::$users['uploader']; + + $params = array( + 'action' => 'login', + 'lgname' => $user->username, + 'lgpassword' => $user->password + ); + list( $result, , $session ) = $this->doApiRequest( $params ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "NeedToken", $result['login']['result'] ); + $token = $result['login']['token']; + + $params = array( + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => $user->username, + 'lgpassword' => $user->password + ); + list( $result, , $session ) = $this->doApiRequest( $params, $session ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "Success", $result['login']['result'] ); + $this->assertArrayHasKey( 'lgtoken', $result['login'] ); + + $this->assertNotEmpty( $session, 'API Login must return a session' ); + + return $session; + } + + /** + * @depends testLogin + */ + public function testUploadRequiresToken( $session ) { + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload' + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "The token parameter must be set", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUploadMissingParams( $session ) { + $exception = false; + try { + $this->doApiRequestWithToken( array( + 'action' => 'upload', + ), $session, self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "One of the parameters filekey, file, url, statuskey is required", + $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUpload( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadZeroLength( $session ) { + $mimeType = 'image/png'; + + $filePath = tempnam( wfTempDir(), "" ); + $fileName = "apiTestUploadZeroLength.png"; + + $this->deleteFileByFileName( $fileName ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + $this->doApiRequestWithToken( $params, $session, self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->assertContains( 'The file you submitted was empty', $e->getMessage() ); + $exception = true; + } + $this->assertTrue( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadSameFileName( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 2, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + // we'll reuse this filename + /** @var array $filePaths */ + $fileName = basename( $filePaths[0] ); + + // clear any other files with the same name + $this->deleteFileByFileName( $fileName ); + + // we reuse these params + $params = array( + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + // first upload .... should succeed + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same name (but different content) + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePaths[0] ); + unlink( $filePaths[1] ); + } + + /** + * @depends testLogin + */ + public function testUploadSameContent( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $fileNames[0] = basename( $filePaths[0] ); + $fileNames[1] = "SameContentAs" . $fileNames[0]; + + // clear any other files with the same name or content + $this->deleteFileByContent( $filePaths[0] ); + $this->deleteFileByFileName( $fileNames[0] ); + $this->deleteFileByFileName( $fileNames[1] ); + + // first upload .... should succeed + + $params = array( + 'action' => 'upload', + 'filename' => $fileNames[0], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[0], + ); + + if ( !$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same content (but different name) + + if ( !$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'filename' => $fileNames[1], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[1], + ); + + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileNames[0] ); + $this->deleteFileByFilename( $fileNames[1] ); + unlink( $filePaths[0] ); + } + + /** + * @depends testLogin + */ + public function testUploadStash( $session ) { + $this->setMwGlobals( array( + 'wgUser' => self::$users['uploader']->user, // @todo FIXME: still used somewhere + ) ); + + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = array( + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ); + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); // FIXME: leaks a temporary file + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertFalse( $exception ); + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] ); + $filekey = $result['upload']['filekey']; + + // it should be visible from Special:UploadStash + // XXX ...but how to test this, with a fake WebRequest with the session? + + // now we should try to release the file from stash + $params = array( + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ); + + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception, "No UsageException exception." ); + + // clean up + $this->deleteFileByFilename( $fileName ); + unlink( $filePath ); + } + + /** + * @depends testLogin + */ + public function testUploadChunks( $session ) { + $this->setMwGlobals( array( + // @todo FIXME: still used somewhere + 'wgUser' => self::$users['uploader']->user, + ) ); + + $chunkSize = 1048576; + // Download a large image file + // ( using RandomImageGenerator for large files is not stable ) + $mimeType = 'image/jpeg'; + $url = 'http://upload.wikimedia.org/wikipedia/commons/' + . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG'; + $filePath = wfTempDir() . '/Oberaargletscher_from_Oberaar.jpg'; + try { + // Only download if the file is not avaliable in the temp location: + if ( !is_file( $filePath ) ) { + copy( $url, $filePath ); + } + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + // Base upload params: + $params = array( + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'filesize' => $fileSize, + 'offset' => 0, + ); + + // Upload chunks + $chunkSessionKey = false; + $resultOffset = 0; + // Open the file: + wfSuppressWarnings(); + $handle = fopen( $filePath, "r" ); + wfRestoreWarnings(); + + if ( $handle === false ) { + $this->markTestIncomplete( "could not open file: $filePath" ); + } + + while ( !feof( $handle ) ) { + // Get the current chunk + wfSuppressWarnings(); + $chunkData = fread( $handle, $chunkSize ); + wfRestoreWarnings(); + + // Upload the current chunk into the $_FILE object: + $this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData ); + + // Check for chunkSessionKey + if ( !$chunkSessionKey ) { + // Upload fist chunk ( and get the session key ) + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + // If we don't get a session key mark test incomplete. + if ( !isset( $result['upload']['filekey'] ) ) { + $this->markTestIncomplete( "no filekey provided" ); + } + $chunkSessionKey = $result['upload']['filekey']; + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // First chunk should have chunkSize == offset + $this->assertEquals( $chunkSize, $result['upload']['offset'] ); + $resultOffset = $result['upload']['offset']; + continue; + } + // Filekey set to chunk session + $params['filekey'] = $chunkSessionKey; + // Update the offset ( always add chunkSize for subquent chunks + // should be in-sync with $result['upload']['offset'] ) + $params['offset'] += $chunkSize; + // Make sure param offset is insync with resultOffset: + $this->assertEquals( $resultOffset, $params['offset'] ); + // Upload current chunk + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + + // Check if we were on the last chunk: + if ( $params['offset'] + $chunkSize >= $fileSize ) { + $this->assertEquals( 'Success', $result['upload']['result'] ); + break; + } else { + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // update $resultOffset + $resultOffset = $result['upload']['offset']; + } + } + fclose( $handle ); + + // Check that we got a valid file result: + wfDebug( __METHOD__ + . " hohoh filesize {$fileSize} info {$result['upload']['imageinfo']['size']}\n\n" ); + $this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $filekey = $result['upload']['filekey']; + + // Now we should try to release the file from stash + $params = array( + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ); + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->user ); + } catch ( UsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFilename( $fileName ); + // don't remove downloaded temporary file for fast subquent tests. + //unlink( $filePath ); + } +} diff --git a/tests/phpunit/includes/api/ApiWatchTest.php b/tests/phpunit/includes/api/ApiWatchTest.php new file mode 100644 index 00000000..e49c6c0e --- /dev/null +++ b/tests/phpunit/includes/api/ApiWatchTest.php @@ -0,0 +1,157 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @todo This test suite is severly broken and need a full review + */ +class ApiWatchTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + $this->doLogin(); + } + + function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + /** + */ + public function testWatchEdit() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'edit', + 'title' => 'Help:UTPage', // Help namespace is hopefully wikitext + 'text' => 'new text', + 'token' => $tokens['edittoken'], + 'watchlist' => 'watch' ) ); + $this->assertArrayHasKey( 'edit', $data[0] ); + $this->assertArrayHasKey( 'result', $data[0]['edit'] ); + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + return $data; + } + + /** + * @depends testWatchEdit + */ + public function testWatchClear() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'wllimit' => 'max', + 'list' => 'watchlist' ) ); + + if ( isset( $data[0]['query']['watchlist'] ) ) { + $wl = $data[0]['query']['watchlist']; + + foreach ( $wl as $page ) { + $data = $this->doApiRequest( array( + 'action' => 'watch', + 'title' => $page['title'], + 'unwatch' => true, + 'token' => $tokens['watchtoken'] ) ); + } + } + $data = $this->doApiRequest( array( + 'action' => 'query', + 'list' => 'watchlist' ), $data ); + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'watchlist', $data[0]['query'] ); + foreach ( $data[0]['query']['watchlist'] as $index => $item ) { + // Previous tests may insert an invalid title + // like ":ApiEditPageTest testNonTextEdit", which + // can't be cleared. + if ( strpos( $item['title'], ':' ) === 0 ) { + unset( $data[0]['query']['watchlist'][$index] ); + } + } + $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) ); + + return $data; + } + + /** + */ + public function testWatchProtect() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( array( + 'action' => 'protect', + 'token' => $tokens['protecttoken'], + 'title' => 'Help:UTPage', + 'protections' => 'edit=sysop', + 'watchlist' => 'unwatch' ) ); + + $this->assertArrayHasKey( 'protect', $data[0] ); + $this->assertArrayHasKey( 'protections', $data[0]['protect'] ); + $this->assertEquals( 1, count( $data[0]['protect']['protections'] ) ); + $this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] ); + } + + /** + */ + public function testGetRollbackToken() { + $this->getTokens(); + + if ( !Title::newFromText( 'Help:UTPage' )->exists() ) { + $this->markTestSkipped( "The article [[Help:UTPage]] does not exist" ); //TODO: just create it? + } + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => 'Help:UTPage', + 'rvtoken' => 'rollback' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + + if ( isset( $data[0]['query']['pages'][$key]['missing'] ) ) { + $this->markTestSkipped( "Target page (Help:UTPage) doesn't exist" ); + } + + $this->assertArrayHasKey( 'pageid', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 'revisions', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 0, $data[0]['query']['pages'][$key]['revisions'] ); + $this->assertArrayHasKey( 'rollbacktoken', $data[0]['query']['pages'][$key]['revisions'][0] ); + + return $data; + } + + /** + * @group Broken + * Broken because there is currently no revision info in the $pageinfo + * + * @depends testGetRollbackToken + */ + public function testWatchRollback( $data ) { + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + $pageinfo = $data[0]['query']['pages'][$key]; + $revinfo = $pageinfo['revisions'][0]; + + try { + $data = $this->doApiRequest( array( + 'action' => 'rollback', + 'title' => 'Help:UTPage', + 'user' => $revinfo['user'], + 'token' => $pageinfo['rollbacktoken'], + 'watchlist' => 'watch' ) ); + + $this->assertArrayHasKey( 'rollback', $data[0] ); + $this->assertArrayHasKey( 'title', $data[0]['rollback'] ); + } catch ( UsageException $ue ) { + if ( $ue->getCodeString() == 'onlyauthor' ) { + $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" ); + } else { + $this->fail( "Received error '" . $ue->getCodeString() . "'" ); + } + } + } +} diff --git a/tests/phpunit/includes/api/MockApi.php b/tests/phpunit/includes/api/MockApi.php new file mode 100644 index 00000000..d94aa2cd --- /dev/null +++ b/tests/phpunit/includes/api/MockApi.php @@ -0,0 +1,20 @@ +<?php + +class MockApi extends ApiBase { + public function execute() { + } + + public function getVersion() { + } + + public function __construct() { + } + + public function getAllowedParams() { + return array( + 'filename' => null, + 'enablechunks' => false, + 'sessionkey' => null, + ); + } +} diff --git a/tests/phpunit/includes/api/MockApiQueryBase.php b/tests/phpunit/includes/api/MockApiQueryBase.php new file mode 100644 index 00000000..4bede519 --- /dev/null +++ b/tests/phpunit/includes/api/MockApiQueryBase.php @@ -0,0 +1,11 @@ +<?php +class MockApiQueryBase extends ApiQueryBase { + public function execute() { + } + + public function getVersion() { + } + + public function __construct() { + } +} diff --git a/tests/phpunit/includes/api/PrefixUniquenessTest.php b/tests/phpunit/includes/api/PrefixUniquenessTest.php new file mode 100644 index 00000000..13da33c7 --- /dev/null +++ b/tests/phpunit/includes/api/PrefixUniquenessTest.php @@ -0,0 +1,30 @@ +<?php + +/** + * Checks that all API query modules, core and extensions, have unique prefixes. + * + * @group API + */ +class PrefixUniquenessTest extends MediaWikiTestCase { + + public function testPrefixes() { + $main = new ApiMain( new FauxRequest() ); + $query = new ApiQuery( $main, 'foo', 'bar' ); + $moduleManager = $query->getModuleManager(); + + $modules = $moduleManager->getNames(); + $prefixes = array(); + + foreach ( $modules as $name ) { + $module = $moduleManager->getModule( $name ); + $class = get_class( $module ); + + $prefix = $module->getModulePrefix(); + if ( isset( $prefixes[$prefix] ) ) { + $this->fail( "Module prefix '{$prefix}' is shared between {$class} and {$prefixes[$prefix]}" ); + } + $prefixes[$module->getModulePrefix()] = $class; + } + $this->assertTrue( true ); // dummy call to make this test non-incomplete + } +} diff --git a/tests/phpunit/includes/api/RandomImageGenerator.php b/tests/phpunit/includes/api/RandomImageGenerator.php new file mode 100644 index 00000000..6374cfac --- /dev/null +++ b/tests/phpunit/includes/api/RandomImageGenerator.php @@ -0,0 +1,496 @@ +<?php +/** + * RandomImageGenerator -- does what it says on the tin. + * Requires Imagick, the ImageMagick library for PHP, or the command line + * equivalent (usually 'convert'). + * + * Because MediaWiki tests the uniqueness of media upload content, and + * filenames, it is sometimes useful to generate files that are guaranteed (or + * at least very likely) to be unique in both those ways. This generates a + * number of filenames with random names and random content (colored triangles). + * + * It is also useful to have fresh content because our tests currently run in a + * "destructive" mode, and don't create a fresh new wiki for each test run. + * Consequently, if we just had a few static files we kept re-uploading, we'd + * get lots of warnings about matching content or filenames, and even if we + * deleted those files, we'd get warnings about archived files. + * + * This can also be used with a cronjob to generate random files all the time. + * I use it to have a constant, never ending supply when I'm testing + * interactively. + * + * @file + * @author Neil Kandalgaonkar <neilk@wikimedia.org> + */ + +/** + * RandomImageGenerator: does what it says on the tin. + * Can fetch a random image, or also write a number of them to disk with random filenames. + */ +class RandomImageGenerator { + private $dictionaryFile; + private $minWidth = 400; + private $maxWidth = 800; + private $minHeight = 400; + private $maxHeight = 800; + private $shapesToDraw = 5; + + /** + * Orientations: 0th row, 0th column, Exif orientation code, rotation 2x2 + * matrix that is opposite of orientation. N.b. we do not handle the + * 'flipped' orientations, which is why there is no entry for 2, 4, 5, or 7. + * Those seem to be rare in real images anyway (we also would need a + * non-symmetric shape for the images to test those, like a letter F). + */ + private static $orientations = array( + array( + '0thRow' => 'top', + '0thCol' => 'left', + 'exifCode' => 1, + 'counterRotation' => array( array( 1, 0 ), array( 0, 1 ) ) + ), + array( + '0thRow' => 'bottom', + '0thCol' => 'right', + 'exifCode' => 3, + 'counterRotation' => array( array( -1, 0 ), array( 0, -1 ) ) + ), + array( + '0thRow' => 'right', + '0thCol' => 'top', + 'exifCode' => 6, + 'counterRotation' => array( array( 0, 1 ), array( 1, 0 ) ) + ), + array( + '0thRow' => 'left', + '0thCol' => 'bottom', + 'exifCode' => 8, + 'counterRotation' => array( array( 0, -1 ), array( -1, 0 ) ) + ) + ); + + public function __construct( $options = array() ) { + foreach ( array( 'dictionaryFile', 'minWidth', 'minHeight', + 'maxWidth', 'maxHeight', 'shapesToDraw' ) as $property + ) { + if ( isset( $options[$property] ) ) { + $this->$property = $options[$property]; + } + } + + // find the dictionary file, to generate random names + if ( !isset( $this->dictionaryFile ) ) { + foreach ( + array( + '/usr/share/dict/words', + '/usr/dict/words', + __DIR__ . '/words.txt' + ) as $dictionaryFile + ) { + if ( is_file( $dictionaryFile ) and is_readable( $dictionaryFile ) ) { + $this->dictionaryFile = $dictionaryFile; + break; + } + } + } + if ( !isset( $this->dictionaryFile ) ) { + throw new Exception( "RandomImageGenerator: dictionary file not " + . "found or not specified properly" ); + } + } + + /** + * Writes random images with random filenames to disk in the directory you + * specify, or current working directory. + * + * @param int $number Number of filenames to write + * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif' + * @param string $dir Directory, optional (will default to current working directory) + * @return array Filenames we just wrote + */ + function writeImages( $number, $format = 'jpg', $dir = null ) { + $filenames = $this->getRandomFilenames( $number, $format, $dir ); + $imageWriteMethod = $this->getImageWriteMethod( $format ); + foreach ( $filenames as $filename ) { + $this->{$imageWriteMethod}( $this->getImageSpec(), $format, $filename ); + } + + return $filenames; + } + + /** + * Figure out how we write images. This is a factor of both format and the local system + * + * @param string $format (a typical extension like 'svg', 'jpg', etc.) + * + * @throws Exception + * @return string + */ + function getImageWriteMethod( $format ) { + global $wgUseImageMagick, $wgImageMagickConvertCommand; + if ( $format === 'svg' ) { + return 'writeSvg'; + } else { + // figure out how to write images + global $wgExiv2Command; + if ( class_exists( 'Imagick' ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) { + return 'writeImageWithApi'; + } elseif ( $wgUseImageMagick + && $wgImageMagickConvertCommand + && is_executable( $wgImageMagickConvertCommand ) + ) { + return 'writeImageWithCommandLine'; + } + } + throw new Exception( "RandomImageGenerator: could not find a suitable " + . "method to write images in '$format' format" ); + } + + /** + * Return a number of randomly-generated filenames + * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg + * + * @param int $number Number of filenames to generate + * @param string $extension Optional, defaults to 'jpg' + * @param string $dir Optional, defaults to current working directory + * @return array Array of filenames + */ + private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) { + if ( is_null( $dir ) ) { + $dir = getcwd(); + } + $filenames = array(); + foreach ( $this->getRandomWordPairs( $number ) as $pair ) { + $basename = $pair[0] . '_' . $pair[1]; + if ( !is_null( $extension ) ) { + $basename .= '.' . $extension; + } + $basename = preg_replace( '/\s+/', '', $basename ); + $filenames[] = "$dir/$basename"; + } + + return $filenames; + } + + /** + * Generate data representing an image of random size (within limits), + * consisting of randomly colored and sized upward pointing triangles + * against a random background color. (This data is used in the + * writeImage* methods). + * + * @return mixed + */ + public function getImageSpec() { + $spec = array(); + + $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth ); + $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight ); + $spec['fill'] = $this->getRandomColor(); + + $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) ); + + $draws = array(); + for ( $i = 0; $i <= $this->shapesToDraw; $i++ ) { + $radius = mt_rand( 0, $diagonalLength / 4 ); + if ( $radius == 0 ) { + continue; + } + $originX = mt_rand( -1 * $radius, $spec['width'] + $radius ); + $originY = mt_rand( -1 * $radius, $spec['height'] + $radius ); + $angle = mt_rand( 0, ( 3.141592 / 2 ) * $radius ) / $radius; + $legDeltaX = round( $radius * sin( $angle ) ); + $legDeltaY = round( $radius * cos( $angle ) ); + + $draw = array(); + $draw['fill'] = $this->getRandomColor(); + $draw['shape'] = array( + array( 'x' => $originX, 'y' => $originY - $radius ), + array( 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ), + array( 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ), + array( 'x' => $originX, 'y' => $originY - $radius ) + ); + $draws[] = $draw; + } + + $spec['draws'] = $draws; + + return $spec; + } + + /** + * Given array( array('x' => 10, 'y' => 20), array( 'x' => 30, y=> 5 ) ) + * returns "10,20 30,5" + * Useful for SVG and imagemagick command line arguments + * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values + * @return string + */ + static function shapePointsToString( $shape ) { + $points = array(); + foreach ( $shape as $point ) { + $points[] = $point['x'] . ',' . $point['y']; + } + + return join( " ", $points ); + } + + /** + * Based on image specification, write a very simple SVG file to disk. + * Ignores the background spec because transparency is cool. :) + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (which is obviously always svg here) + * @param string $filename Filename to write to + * + * @throws Exception + */ + public function writeSvg( $spec, $format, $filename ) { + $svg = new SimpleXmlElement( '<svg/>' ); + $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' ); + $svg->addAttribute( 'version', '1.1' ); + $svg->addAttribute( 'width', $spec['width'] ); + $svg->addAttribute( 'height', $spec['height'] ); + $g = $svg->addChild( 'g' ); + foreach ( $spec['draws'] as $drawSpec ) { + $shape = $g->addChild( 'polygon' ); + $shape->addAttribute( 'fill', $drawSpec['fill'] ); + $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) ); + } + + if ( !$fh = fopen( $filename, 'w' ) ) { + throw new Exception( "couldn't open $filename for writing" ); + } + fwrite( $fh, $svg->asXML() ); + if ( !fclose( $fh ) ) { + throw new Exception( "couldn't close $filename" ); + } + } + + /** + * Based on an image specification, write such an image to disk, using Imagick PHP extension + * @param array $spec Spec describing background and circles to draw + * @param string $format File format to write + * @param string $filename Filename to write to + */ + public function writeImageWithApi( $spec, $format, $filename ) { + // this is a hack because I can't get setImageOrientation() to work. See below. + global $wgExiv2Command; + + $image = new Imagick(); + /** + * If the format is 'jpg', will also add a random orientation -- the + * image will be drawn rotated with triangle points facing in some + * direction (0, 90, 180 or 270 degrees) and a countering rotation + * should turn the triangle points upward again. + */ + $orientation = self::$orientations[0]; // default is normal orientation + if ( $format == 'jpg' ) { + $orientation = self::$orientations[array_rand( self::$orientations )]; + $spec = self::rotateImageSpec( $spec, $orientation['counterRotation'] ); + } + + $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) ); + + foreach ( $spec['draws'] as $drawSpec ) { + $draw = new ImagickDraw(); + $draw->setFillColor( $drawSpec['fill'] ); + $draw->polygon( $drawSpec['shape'] ); + $image->drawImage( $draw ); + } + + $image->setImageFormat( $format ); + + // this doesn't work, even though it's documented to do so... + // $image->setImageOrientation( $orientation['exifCode'] ); + + $image->writeImage( $filename ); + + // because the above setImageOrientation call doesn't work... nor can I + // get an external imagemagick binary to do this either... Hacking this + // for now (only works if you have exiv2 installed, a program to read + // and manipulate exif). + if ( $wgExiv2Command ) { + $cmd = wfEscapeShellArg( $wgExiv2Command ) + . " -M " + . wfEscapeShellArg( "set Exif.Image.Orientation " . $orientation['exifCode'] ) + . " " + . wfEscapeShellArg( $filename ); + + $retval = 0; + $err = wfShellExec( $cmd, $retval ); + if ( $retval !== 0 ) { + print "Error with $cmd: $retval, $err\n"; + } + } + } + + /** + * Given an image specification, produce rotated version + * This is used when simulating a rotated image capture with Exif orientation + * @param array $spec Returned by getImageSpec + * @param array $matrix 2x2 transformation matrix + * @return array Transformed Spec + */ + private static function rotateImageSpec( &$spec, $matrix ) { + $tSpec = array(); + $dims = self::matrixMultiply2x2( $matrix, $spec['width'], $spec['height'] ); + $correctionX = 0; + $correctionY = 0; + if ( $dims['x'] < 0 ) { + $correctionX = abs( $dims['x'] ); + } + if ( $dims['y'] < 0 ) { + $correctionY = abs( $dims['y'] ); + } + $tSpec['width'] = abs( $dims['x'] ); + $tSpec['height'] = abs( $dims['y'] ); + $tSpec['fill'] = $spec['fill']; + $tSpec['draws'] = array(); + foreach ( $spec['draws'] as $draw ) { + $tDraw = array( + 'fill' => $draw['fill'], + 'shape' => array() + ); + foreach ( $draw['shape'] as $point ) { + $tPoint = self::matrixMultiply2x2( $matrix, $point['x'], $point['y'] ); + $tPoint['x'] += $correctionX; + $tPoint['y'] += $correctionY; + $tDraw['shape'][] = $tPoint; + } + $tSpec['draws'][] = $tDraw; + } + + return $tSpec; + } + + /** + * Given a matrix and a pair of images, return new position + * @param array $matrix 2x2 rotation matrix + * @param int $x The x-coordinate number + * @param int $y The y-coordinate number + * @return array Transformed with properties x, y + */ + private static function matrixMultiply2x2( $matrix, $x, $y ) { + return array( + 'x' => $x * $matrix[0][0] + $y * $matrix[0][1], + 'y' => $x * $matrix[1][0] + $y * $matrix[1][1] + ); + } + + /** + * Based on an image specification, write such an image to disk, using the + * command line ImageMagick program ('convert'). + * + * Sample command line: + * $ convert -size 100x60 xc:rgb(90,87,45) \ + * -draw 'fill rgb(12,34,56) polygon 41,39 44,57 50,57 41,39' \ + * -draw 'fill rgb(99,123,231) circle 59,39 56,57' \ + * -draw 'fill rgb(240,12,32) circle 50,21 50,3' filename.png + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (unused by this method but + * kept so it has the same signature as writeImageWithApi). + * @param string $filename Filename to write to + * + * @return bool + */ + public function writeImageWithCommandLine( $spec, $format, $filename ) { + global $wgImageMagickConvertCommand; + $args = array(); + $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] ); + $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] ); + foreach ( $spec['draws'] as $draw ) { + $fill = $draw['fill']; + $polygon = self::shapePointsToString( $draw['shape'] ); + $drawCommand = "fill $fill polygon $polygon"; + $args[] = '-draw ' . wfEscapeShellArg( $drawCommand ); + } + $args[] = wfEscapeShellArg( $filename ); + + $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args ); + $retval = null; + wfShellExec( $command, $retval ); + + return ( $retval === 0 ); + } + + /** + * Generate a string of random colors for ImageMagick or SVG, like "rgb(12, 37, 98)" + * + * @return string + */ + public function getRandomColor() { + $components = array(); + for ( $i = 0; $i <= 2; $i++ ) { + $components[] = mt_rand( 0, 255 ); + } + + return 'rgb(' . join( ', ', $components ) . ')'; + } + + /** + * Get an array of random pairs of random words, like + * array( array( 'foo', 'bar' ), array( 'quux', 'baz' ) ); + * + * @param int $number Number of pairs + * @return array Two-element arrays + */ + private function getRandomWordPairs( $number ) { + $lines = $this->getRandomLines( $number * 2 ); + // construct pairs of words + $pairs = array(); + $count = count( $lines ); + for ( $i = 0; $i < $count; $i += 2 ) { + $pairs[] = array( $lines[$i], $lines[$i + 1] ); + } + + return $pairs; + } + + /** + * Return N random lines from a file + * + * Will throw exception if the file could not be read or if it had fewer lines than requested. + * + * @param int $number_desired Number of lines desired + * + * @throws Exception + * @return array Array of exactly n elements, drawn randomly from lines the file + */ + private function getRandomLines( $number_desired ) { + $filepath = $this->dictionaryFile; + + // initialize array of lines + $lines = array(); + for ( $i = 0; $i < $number_desired; $i++ ) { + $lines[] = null; + } + + /* + * This algorithm obtains N random lines from a file in one single pass. + * It does this by replacing elements of a fixed-size array of lines, + * less and less frequently as it reads the file. + */ + $fh = fopen( $filepath, "r" ); + if ( !$fh ) { + throw new Exception( "couldn't open $filepath" ); + } + $line_number = 0; + $max_index = $number_desired - 1; + while ( !feof( $fh ) ) { + $line = fgets( $fh ); + if ( $line !== false ) { + $line_number++; + $line = trim( $line ); + if ( mt_rand( 0, $line_number ) <= $max_index ) { + $lines[mt_rand( 0, $max_index )] = $line; + } + } + } + fclose( $fh ); + if ( $line_number < $number_desired ) { + throw new Exception( "not enough lines in $filepath" ); + } + + return $lines; + } +} diff --git a/tests/phpunit/includes/api/UserWrapper.php b/tests/phpunit/includes/api/UserWrapper.php new file mode 100644 index 00000000..f8da0ff4 --- /dev/null +++ b/tests/phpunit/includes/api/UserWrapper.php @@ -0,0 +1,25 @@ +<?php + +class UserWrapper { + public $userName; + public $password; + public $user; + + public function __construct( $userName, $password, $group = '' ) { + $this->userName = $userName; + $this->password = $password; + + $this->user = User::newFromName( $this->userName ); + if ( !$this->user->getID() ) { + $this->user = User::createNew( $this->userName, array( + "email" => "test@example.com", + "real_name" => "Test User" ) ); + } + $this->user->setPassword( $this->password ); + + if ( $group !== '' ) { + $this->user->addGroup( $group ); + } + $this->user->saveSettings(); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php new file mode 100644 index 00000000..fc1f9021 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php @@ -0,0 +1,22 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiFormatJson + */ +class ApiFormatJsonTest extends ApiFormatTestBase { + + public function testValidSyntax( ) { + $data = $this->apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', json_decode( $data, true ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } + + public function testJsonpInjection( ) { + $data = $this->apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo', 'callback' => 'myCallback' ) ); + $this->assertEquals( '/**/myCallback(', substr( $data, 0, 15 ) ); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php new file mode 100644 index 00000000..cabd750b --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php @@ -0,0 +1,16 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiFormatNone + */ +class ApiFormatNoneTest extends ApiFormatTestBase { + + public function testValidSyntax( ) { + $data = $this->apiRequest( 'none', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertEquals( '', $data ); // No output! + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php new file mode 100644 index 00000000..54f447a9 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php @@ -0,0 +1,17 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiFormatPhp + */ +class ApiFormatPhpTest extends ApiFormatTestBase { + + public function testValidSyntax( ) { + $data = $this->apiRequest( 'php', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', unserialize( $data ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } +} diff --git a/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/tests/phpunit/includes/api/format/ApiFormatTestBase.php new file mode 100644 index 00000000..5f6d53ce --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatTestBase.php @@ -0,0 +1,32 @@ +<?php + +abstract class ApiFormatTestBase extends ApiTestCase { + + /** + * @param string $format + * @param array $params + * @param array $data + * + * @return string + */ + protected function apiRequest( $format, $params, $data = null ) { + $data = parent::doApiRequest( $params, $data, true ); + + /** @var ApiMain $module */ + $module = $data[3]; + + $printer = $module->createPrinterByName( $format ); + $printer->setUnescapeAmps( false ); + + $printer->initPrinter( false ); + + ob_start(); + $printer->execute(); + $out = ob_get_clean(); + + $printer->closePrinter(); + + return $out; + } + +} diff --git a/tests/phpunit/includes/api/format/ApiFormatWddxTest.php b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php new file mode 100644 index 00000000..d075f547 --- /dev/null +++ b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php @@ -0,0 +1,20 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiFormatWddx + */ +class ApiFormatWddxTest extends ApiFormatTestBase { + + /** + * @requires function wddx_deserialize + */ + public function testValidSyntax( ) { + $data = $this->apiRequest( 'wddx', array( 'action' => 'query', 'meta' => 'siteinfo' ) ); + + $this->assertInternalType( 'array', wddx_deserialize( $data ) ); + $this->assertGreaterThan( 0, count( (array)$data ) ); + } +} diff --git a/tests/phpunit/includes/api/generateRandomImages.php b/tests/phpunit/includes/api/generateRandomImages.php new file mode 100644 index 00000000..87f5c4c0 --- /dev/null +++ b/tests/phpunit/includes/api/generateRandomImages.php @@ -0,0 +1,46 @@ +<?php +/** + * Bootstrapping for test image file generation + * + * @file + */ + +// Start up MediaWiki in command-line mode +require_once __DIR__ . "/../../../../maintenance/Maintenance.php"; +require __DIR__ . "/RandomImageGenerator.php"; + +class GenerateRandomImages extends Maintenance { + + public function getDbType() { + return Maintenance::DB_NONE; + } + + public function execute() { + + $getOptSpec = array( + 'dictionaryFile::', + 'minWidth::', + 'maxWidth::', + 'minHeight::', + 'maxHeight::', + 'shapesToDraw::', + 'shape::', + + 'number::', + 'format::' + ); + $options = getopt( null, $getOptSpec ); + + $format = isset( $options['format'] ) ? $options['format'] : 'jpg'; + unset( $options['format'] ); + + $number = isset( $options['number'] ) ? intval( $options['number'] ) : 10; + unset( $options['number'] ); + + $randomImageGenerator = new RandomImageGenerator( $options ); + $randomImageGenerator->writeImages( $number, $format ); + } +} + +$maintClass = 'GenerateRandomImages'; +require RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php new file mode 100644 index 00000000..e486c4f4 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php @@ -0,0 +1,353 @@ +<?php +/** + * + * Created on Feb 6, 2013 + * + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +require_once 'ApiQueryTestBase.php'; + +/** + * These tests validate basic functionality of the api query module + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryBasicTest extends ApiQueryTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + if ( Title::newFromText( 'AQBT-All' )->exists() ) { + return; + } + + // Ordering is important, as it will be returned in the same order as stored in the index + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' ); + $this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + $this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE ); + + // Refresh due to the bug with listing transclusions as links if they don't exist + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + private static $links = array( + array( 'prop' => 'links', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'links' => array( + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + ) + ) + ) ) + ); + + private static $templates = array( + array( 'prop' => 'templates', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + ) + ) + ) ) + ); + + private static $categories = array( + array( 'prop' => 'categories', 'titles' => 'AQBT-All' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'categories' => array( + array( 'ns' => 14, 'title' => 'Category:AQBT-Cat' ), + ) + ) + ) ) + ); + + private static $allpages = array( + array( 'list' => 'allpages', 'apprefix' => 'AQBT-' ), + array( 'allpages' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ), + array( 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ), + array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $alllinks = array( + array( 'list' => 'alllinks', 'alprefix' => 'AQBT-' ), + array( 'alllinks' => array( + array( 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'ns' => 0, 'title' => 'AQBT-Categories' ), + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + array( 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $alltransclusions = array( + array( 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ), + array( 'alltransclusions' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ), + ) ) + ); + + // Although this appears to have no use it is used by testLists() + private static $allcategories = array( + array( 'list' => 'allcategories', 'acprefix' => 'AQBT-' ), + array( 'allcategories' => array( + array( '*' => 'AQBT-Cat' ), + ) ) + ); + + private static $backlinks = array( + array( 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ), + array( 'backlinks' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + ) ) + ); + + private static $embeddedin = array( + array( 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ), + array( 'embeddedin' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $categorymembers = array( + array( 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ), + array( 'categorymembers' => array( + array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ), + array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ), + ) ) + ); + + private static $generatorAllpages = array( + array( 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ), + '2' => array( + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ), + '3' => array( + 'pageid' => 3, + 'ns' => 0, + 'title' => 'AQBT-Links' ), + '4' => array( + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $generatorLinks = array( + array( 'generator' => 'links', 'titles' => 'AQBT-Links' ), + array( 'pages' => array( + '1' => array( + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ), + '2' => array( + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ), + '4' => array( + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ), + ) ) + ); + + private static $generatorLinksPropLinks = array( + array( 'prop' => 'links' ), + array( 'pages' => array( + '1' => array( 'links' => array( + array( 'ns' => 0, 'title' => 'AQBT-Links' ), + ) ) + ) ) + ); + + private static $generatorLinksPropTemplates = array( + array( 'prop' => 'templates' ), + array( 'pages' => array( + '1' => array( 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ) ) ), + '4' => array( 'templates' => array( + array( 'ns' => 10, 'title' => 'Template:AQBT-T' ) ) ), + ) ) + ); + + /** + * Test basic props + */ + public function testProps() { + $this->check( self::$links ); + $this->check( self::$templates ); + $this->check( self::$categories ); + } + + /** + * Test basic lists + */ + public function testLists() { + $this->check( self::$allpages ); + $this->check( self::$alllinks ); + $this->check( self::$alltransclusions ); + // This test is temporarily disabled until a sqlite bug is fixed + // Confirmed still broken 15-nov-2013 + // $this->check( self::$allcategories ); + $this->check( self::$backlinks ); + $this->check( self::$embeddedin ); + $this->check( self::$categorymembers ); + } + + /** + * Test basic lists + */ + public function testAllTogether() { + + // All props together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories + ) ); + + // All lists together + $this->check( $this->merge( + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + + // All props+lists together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + } + + /** + * Test basic lists + */ + public function testGenerator() { + // generator=allpages + $this->check( self::$generatorAllpages ); + // generator=allpages & list=allpages + $this->check( $this->merge( + self::$generatorAllpages, + self::$allpages ) ); + // generator=links + $this->check( self::$generatorLinks ); + // generator=links & prop=links + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks ) ); + // generator=links & prop=templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates & list=allpages|... + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers ) ); + } + + /** + * Test bug 51821 + */ + public function testGeneratorRedirects() { + $this->editPage( 'AQBT-Target', 'test' ); + $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' ); + $this->check( array( + array( 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ), + array( + 'redirects' => array( + array( + 'from' => 'AQBT-Redir', + 'to' => 'AQBT-Target', + ) + ), + 'pages' => array( + '6' => array( + 'pageid' => 6, + 'ns' => 0, + 'title' => 'AQBT-Target', + ) + ), + ) + ) ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php new file mode 100644 index 00000000..347cd6f8 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +require_once 'ApiQueryContinueTestBase.php'; + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinue2Test extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * @medium + */ + public function testA() { + $this->mVerbose = false; + $mk = function ( $g, $p, $gDir ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT73462-', + 'prop' => 'links', + 'gaplimit' => "$g", + 'pllimit' => "$p", + 'gapdir' => $gDir ? "ascending" : "descending", + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ); + $this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' ); + $this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' ); + $this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' ); + $this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php new file mode 100644 index 00000000..03797901 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php @@ -0,0 +1,316 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +require_once 'ApiQueryContinueTestBase.php'; + +/** + * These tests validate the new continue functionality of the api query module by + * doing multiple requests with varying parameters, merging the results, and checking + * that the result matches the full data received in one no-limits call. + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinueTest extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * @see MediaWikiTestCase::addDBData() + */ + function addDBData() { + try { + $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' ); + $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' ); + $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' ); + $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' ); + $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' ); + + $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' ); + $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * Test smart continue - list=allpages + * @medium + */ + public function test1List() { + $this->mVerbose = false; + $mk = function ( $l ) { + return array( + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + ); + }; + $data = $this->query( $mk( 99 ), 1, '1L', false ); + + // 1 list + $this->checkC( $data, $mk( 1 ), 5, '1L-1' ); + $this->checkC( $data, $mk( 2 ), 3, '1L-2' ); + $this->checkC( $data, $mk( 3 ), 2, '1L-3' ); + $this->checkC( $data, $mk( 4 ), 2, '1L-4' ); + $this->checkC( $data, $mk( 5 ), 1, '1L-5' ); + } + + /** + * Test smart continue - list=allpages|alltransclusions + * @medium + */ + public function test2Lists() { + $this->mVerbose = false; + $mk = function ( $l1, $l2 ) { + return array( + 'list' => 'allpages|alltransclusions', + 'apprefix' => 'AQCT-', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'aplimit' => "$l1", + 'atlimit' => "$l2", + ); + }; + // 2 lists + $data = $this->query( $mk( 99, 99 ), 1, '2L', false ); + $this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' ); + $this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' ); + $this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' ); + $this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' ); + $this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links + * @medium + */ + public function testGen1Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + ); + }; + // generator + 1 prop + $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ); + $this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' ); + $this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' ); + $this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' ); + $this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' ); + $this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates + * @medium + */ + public function testGen2Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2 ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + ); + }; + // generator + 2 props + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ); + $this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' ); + $this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' ); + $this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' ); + $this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' ); + $this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' ); + $this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' ); + } + + /** + * Test smart continue - generator=allpages, prop=links, list=alltransclusions + * @medium + */ + public function testGen1Prop1List() { + $this->mVerbose = false; + $mk = function ( $g, $p, $l ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + 'list' => 'alltransclusions', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l", + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ); + $this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' ); + $this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' ); + $this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates, + * list=alllinks|alltransclusions, meta=siteinfo + * @medium + */ + public function testGen2Prop2List1Meta() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2, $l1, $l2 ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + 'list' => 'alllinks|alltransclusions', + 'alprefix' => 'AQCT-', + 'alunique' => '', + 'allimit' => "$l1", + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l2", + 'meta' => 'siteinfo', + 'siprop' => 'namespaces', + ); + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ); + $this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' ); + $this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' ); + $this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' ); + $this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' ); + $this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' ); + $this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' ); + $this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' ); + $this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' ); + $this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' ); + } + + /** + * Test smart continue - generator=templates, prop=templates + * @medium + */ + public function testSameGenAndProp() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $p, $pDir ) { + return array( + 'titles' => 'AQCT-1', + 'generator' => 'templates', + 'gtllimit' => "$g", + 'gtldir' => $gDir ? 'ascending' : 'descending', + 'prop' => 'templates', + 'tllimit' => "$p", + 'tldir' => $pDir ? 'ascending' : 'descending', + ); + }; + // generator + 1 prop + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ); + + $this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' ); + + $this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' ); + + $this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' ); + + $this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' ); + } + + /** + * Test smart continue - generator=allpages, list=allpages + * @medium + */ + public function testSameGenList() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $l, $pDir ) { + return array( + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'gapdir' => $gDir ? 'ascending' : 'descending', + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + 'apdir' => $pDir ? 'ascending' : 'descending', + ); + }; + // generator + 1 list + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ); + + $this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' ); + $this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' ); + $this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' ); + $this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php new file mode 100644 index 00000000..bce62685 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php @@ -0,0 +1,218 @@ +<?php +/** + * Created on Jan 1, 2013 + * + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +require_once 'ApiQueryTestBase.php'; + +abstract class ApiQueryContinueTestBase extends ApiQueryTestBase { + + /** + * Enable to print in-depth debugging info during the test run + */ + protected $mVerbose = false; + + /** + * Run query() and compare against expected values + * @param array $expected + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $continue True to use smart continue + * @return array Merged results data array + */ + protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) { + $result = $this->query( $params, $expectedCount, $id, $continue ); + $this->assertResult( $expected, $result, $id ); + } + + /** + * Run query in a loop until no more values are available + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $useContinue True to use smart continue + * @return array Merged results data array + * @throws Exception + */ + protected function query( $params, $expectedCount, $id, $useContinue = true ) { + if ( isset( $params['action'] ) ) { + $this->assertEquals( 'query', $params['action'], 'Invalid query action' ); + } else { + $params['action'] = 'query'; + } + if ( $useContinue && !isset( $params['continue'] ) ) { + $params['continue'] = ''; + } + $count = 0; + $result = array(); + $continue = array(); + do { + $request = array_merge( $params, $continue ); + uksort( $request, function ( $a, $b ) { + // put 'continue' params at the end - lazy method + $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a; + $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b; + + return strcmp( $a, $b ); + } ); + $reqStr = http_build_query( $request ); + //$reqStr = str_replace( '&', ' & ', $reqStr ); + $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" ); + if ( $this->mVerbose ) { + print "$id (#$count): $reqStr\n"; + } + try { + $data = $this->doApiRequest( $request ); + } catch ( Exception $e ) { + throw new Exception( "$id on $count", 0, $e ); + } + $data = $data[0]; + if ( isset( $data['warnings'] ) ) { + $warnings = json_encode( $data['warnings'] ); + $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" ); + } + $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" ); + if ( isset( $data['continue'] ) ) { + $continue = $data['continue']; + unset( $data['continue'] ); + } else { + $continue = array(); + } + if ( $this->mVerbose ) { + $this->printResult( $data ); + } + $this->mergeResult( $result, $data ); + $count++; + if ( empty( $continue ) ) { + $this->assertEquals( $expectedCount, $count, "$id finished early" ); + + return $result; + } elseif ( !$useContinue ) { + $this->assertFalse( 'Non-smart query must be requested all at once' ); + } + } while ( true ); + } + + /** + * @param array $data + */ + private function printResult( $data ) { + $q = $data['query']; + $print = array(); + if ( isset( $q['pages'] ) ) { + foreach ( $q['pages'] as $p ) { + $m = $p['title']; + if ( isset( $p['links'] ) ) { + $m .= '/[' . implode( ',', array_map( + function ( $v ) { + return $v['title']; + }, + $p['links'] ) ) . ']'; + } + if ( isset( $p['categories'] ) ) { + $m .= '/(' . implode( ',', array_map( + function ( $v ) { + return str_replace( 'Category:', '', $v['title'] ); + }, + $p['categories'] ) ) . ')'; + } + $print[] = $m; + } + } + if ( isset( $q['allcategories'] ) ) { + $print[] = '*Cats/(' . implode( ',', array_map( + function ( $v ) { + return $v['*']; + }, + $q['allcategories'] ) ) . ')'; + } + self::GetItems( $q, 'allpages', 'Pages', $print ); + self::GetItems( $q, 'alllinks', 'Links', $print ); + self::GetItems( $q, 'alltransclusions', 'Trnscl', $print ); + print ' ' . implode( ' ', $print ) . "\n"; + } + + private static function GetItems( $q, $moduleName, $name, &$print ) { + if ( isset( $q[$moduleName] ) ) { + $print[] = "*$name/[" . implode( ',', + array_map( + function ( $v ) { + return $v['title']; + }, + $q[$moduleName] ) ) . ']'; + } + } + + /** + * Recursively merge the new result returned from the query to the previous results. + * @param mixed $results + * @param mixed $newResult + * @param bool $numericIds If true, treat keys as ids to be merged instead of appending + */ + protected function mergeResult( &$results, $newResult, $numericIds = false ) { + $this->assertEquals( + is_array( $results ), + is_array( $newResult ), + 'Type of result and data do not match' + ); + if ( !is_array( $results ) ) { + $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' ); + } else { + $sort = null; + foreach ( $newResult as $key => $value ) { + if ( !$numericIds && $sort === null ) { + if ( !is_array( $value ) ) { + $sort = false; + } elseif ( array_key_exists( 'title', $value ) ) { + $sort = function ( $a, $b ) { + return strcmp( $a['title'], $b['title'] ); + }; + } else { + $sort = false; + } + } + $keyExists = array_key_exists( $key, $results ); + if ( is_numeric( $key ) ) { + if ( $numericIds ) { + if ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value ); + } + } else { + $results[] = $value; + } + } elseif ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value, $key === 'pages' ); + } + } + if ( $numericIds ) { + ksort( $results, SORT_NUMERIC ); + } elseif ( $sort !== null && $sort !== false ) { + usort( $results, $sort ); + } + } + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php new file mode 100644 index 00000000..74ceff90 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php @@ -0,0 +1,40 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQueryRevisions + */ +class ApiQueryRevisionsTest extends ApiTestCase { + + /** + * @group medium + */ + public function testContentComesWithContentModelAndFormat() { + $pageName = 'Help:' . __METHOD__; + $title = Title::newFromText( $pageName ); + $page = WikiPage::factory( $title ); + $page->doEdit( 'Some text', 'inserting content' ); + + $apiResult = $this->doApiRequest( array( + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => $pageName, + 'rvprop' => 'content', + ) ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'pages', $apiResult[0]['query'] ); + foreach ( $apiResult[0]['query']['pages'] as $page ) { + $this->assertArrayHasKey( 'revisions', $page ); + foreach ( $page['revisions'] as $revision ) { + $this->assertArrayHasKey( 'contentformat', $revision, + 'contentformat should be included when asking content so client knows how to interpret it' + ); + $this->assertArrayHasKey( 'contentmodel', $revision, + 'contentmodel should be included when asking content so client knows how to interpret it' + ); + } + } + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php new file mode 100644 index 00000000..bba22c77 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -0,0 +1,130 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryTest extends ApiTestCase { + /** + * @var array Storage for $wgHooks + */ + protected $hooks; + + protected function setUp() { + global $wgHooks; + + parent::setUp(); + $this->doLogin(); + + // Setup en: as interwiki prefix + $this->hooks = $wgHooks; + $wgHooks['InterwikiLoadPrefix'][] = function ( $prefix, &$data ) { + if ( $prefix == 'apiquerytestiw' ) { + $data = array( 'iw_url' => 'wikipedia' ); + } + return false; + }; + } + + protected function tearDown() { + global $wgHooks; + $wgHooks = $this->hooks; + + parent::tearDown(); + } + + public function testTitlesGetNormalized() { + global $wgMetaNamespace; + + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'titles' => 'Project:articleA|article_B' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'normalized', $data[0]['query'] ); + + // Forge a normalized title + $to = Title::newFromText( $wgMetaNamespace . ':ArticleA' ); + + $this->assertEquals( + array( + 'from' => 'Project:articleA', + 'to' => $to->getPrefixedText(), + ), + $data[0]['query']['normalized'][0] + ); + + $this->assertEquals( + array( + 'from' => 'article_B', + 'to' => 'Article B' + ), + $data[0]['query']['normalized'][1] + ); + } + + public function testTitlesAreRejectedIfInvalid() { + $title = false; + while ( !$title || Title::newFromText( $title )->exists() ) { + $title = md5( mt_rand( 0, 10000 ) + rand( 0, 999000 ) ); + } + + $data = $this->doApiRequest( array( + 'action' => 'query', + 'titles' => $title . '|Talk:' ) ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $this->assertEquals( 2, count( $data[0]['query']['pages'] ) ); + + $this->assertArrayHasKey( -2, $data[0]['query']['pages'] ); + $this->assertArrayHasKey( -1, $data[0]['query']['pages'] ); + + $this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] ); + $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] ); + } + + /** + * Test the ApiBase::titlePartToKey function + * + * @param string $titlePart + * @param int $namespace + * @param string $expected + * @param string $expectException + * @dataProvider provideTestTitlePartToKey + */ + function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $api = new MockApiQueryBase(); + $exceptionCaught = false; + try { + $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) ); + } catch ( UsageException $e ) { + $exceptionCaught = true; + } + $this->assertEquals( $expectException, $exceptionCaught, + 'UsageException thrown by titlePartToKey' ); + } + + function provideTestTitlePartToKey() { + return array( + array( 'a b c', NS_MAIN, 'A_b_c', false ), + array( 'x', NS_MAIN, 'X', false ), + array( 'y ', NS_MAIN, 'Y_', false ), + array( 'template:foo', NS_CATEGORY, 'Template:foo', false ), + array( 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ), + array( "\xF7", NS_MAIN, null, true ), + array( 'template:foo', NS_MAIN, null, true ), + array( 'apiquerytestiw:foo', NS_MAIN, null, true ), + ); + } +} diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php new file mode 100644 index 00000000..56c15b23 --- /dev/null +++ b/tests/phpunit/includes/api/query/ApiQueryTestBase.php @@ -0,0 +1,148 @@ +<?php +/** + * Created on Feb 10, 2013 + * + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** This class has some common functionality for testing query module + */ +abstract class ApiQueryTestBase extends ApiTestCase { + + const PARAM_ASSERT = <<<STR +Each parameter must be an array of two elements, +first - an array of params to the API call, +and the second array - expected results as returned by the API +STR; + + /** + * Merges all requests parameter + expected values into one + * @param array $v,... List of arrays, each of which contains exactly two + * @return array + */ + protected function merge( /*...*/ ) { + $request = array(); + $expected = array(); + foreach ( func_get_args() as $v ) { + list( $req, $exp ) = $this->validateRequestExpectedPair( $v ); + $request = array_merge_recursive( $request, $req ); + $this->mergeExpected( $expected, $exp ); + } + + return array( $request, $expected ); + } + + /** + * Check that the parameter is a valid two element array, + * with the first element being API request and the second - expected result + * @param array $v + * @return array + */ + private function validateRequestExpectedPair( $v ) { + $this->assertType( 'array', $v, self::PARAM_ASSERT ); + $this->assertEquals( 2, count( $v ), self::PARAM_ASSERT ); + $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT ); + $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT ); + $this->assertType( 'array', $v[0], self::PARAM_ASSERT ); + $this->assertType( 'array', $v[1], self::PARAM_ASSERT ); + + return $v; + } + + /** + * Recursively merges the expected values in the $item into the $all + * @param array &$all + * @param array $item + */ + private function mergeExpected( &$all, $item ) { + foreach ( $item as $k => $v ) { + if ( array_key_exists( $k, $all ) ) { + if ( is_array( $all[$k] ) ) { + $this->mergeExpected( $all[$k], $v ); + } else { + $this->assertEquals( $all[$k], $v ); + } + } else { + $all[$k] = $v; + } + } + } + + /** + * Checks that the request's result matches the expected results. + * @param array $values Array is a two element array( request, expected_results ) + * @throws Exception + */ + protected function check( $values ) { + list( $req, $exp ) = $this->validateRequestExpectedPair( $values ); + if ( !array_key_exists( 'action', $req ) ) { + $req['action'] = 'query'; + } + foreach ( $req as &$val ) { + if ( is_array( $val ) ) { + $val = implode( '|', array_unique( $val ) ); + } + } + $result = $this->doApiRequest( $req ); + $this->assertResult( array( 'query' => $exp ), $result[0], $req ); + } + + protected function assertResult( $exp, $result, $message = '' ) { + try { + $exp = self::sanitizeResultArray( $exp ); + $result = self::sanitizeResultArray( $result ); + $this->assertEquals( $exp, $result ); + } catch ( PHPUnit_Framework_ExpectationFailedException $e ) { + if ( is_array( $message ) ) { + $message = http_build_query( $message ); + } + throw new PHPUnit_Framework_ExpectationFailedException( + $e->getMessage() . "\nRequest: $message", + new PHPUnit_Framework_ComparisonFailure( + $exp, + $result, + print_r( $exp, true ), + print_r( $result, true ), + false, + $e->getComparisonFailure()->getMessage() . "\nRequest: $message" + ) + ); + } + } + + /** + * Recursively ksorts a result array and removes any 'pageid' keys. + * @param array $result + * @return array + */ + private static function sanitizeResultArray( $result ) { + unset( $result['pageid'] ); + foreach ( $result as $key => $value ) { + if ( is_array( $value ) ) { + $result[$key] = self::sanitizeResultArray( $value ); + } + } + + // Sort the result by keys, then take advantage of how array_merge will + // renumber numeric keys while leaving others alone. + ksort( $result ); + return array_merge( $result ); + } +} diff --git a/tests/phpunit/includes/api/words.txt b/tests/phpunit/includes/api/words.txt new file mode 100644 index 00000000..7ce23ee3 --- /dev/null +++ b/tests/phpunit/includes/api/words.txt @@ -0,0 +1,1000 @@ +Andaquian +Anoplanthus +Araquaju +Astrophyton +Avarish +Batonga +Bdellidae +Betoyan +Bismarck +Britishness +Carmen +Chatillon +Clement +Coryphaena +Croton +Cyrillianism +Dagomba +Decimus +Dichorisandra +Duculinae +Empusa +Escallonia +Fathometer +Fon +Fundulinae +Gadswoons +Gederathite +Gemini +Gerbera +Gregarinida +Gyracanthus +Halopsychidae +Hasidim +Hemerobius +Ichthyosauridae +Iscariot +Jeames +Jesuitry +Jovian +Judaization +Katie +Ladin +Langhian +Lapithaean +Lisette +Macrochira +Malaxis +Malvastrum +Maranhao +Marxian +Maurist +Metrosideros +Micky +Microsporon +Odacidae +Ophiuchid +Osmorhiza +Paguma +Palesman +Papayaceae +Pastinaca +Philoxenian +Pleurostigma +Rarotongan +Rhodoraceae +Rong +Saho +Sanyakoan +Sardanapalian +Sauropoda +Sedentaria +Shambu +Shukulumbwe +Solonian +Spaniardization +Spirochaetaceae +Stomatopoda +Stratiotes +Taiwanhemp +Titanically +Venetianed +Victrola +Yuman +abatis +abaton +abjoint +acanthoma +acari +acceptance +actinography +acuteness +addiment +adelite +adelomorphic +adelphogamy +adipocele +aelurophobia +affined +aflaunt +agathokakological +aischrolatreia +alarmedly +alebench +aleurone +allelotropic +allerion +alloplastic +allowable +alternacy +alternariose +altricial +ambitionist +amendment +amiableness +amicableness +ammo +amortizable +anchorate +anemometrically +angelocracy +angelological +anodal +anomalure +antedate +antiagglutinin +antirationalist +antiscorbutic +antisplasher +antithesize +antiunionist +antoecian +apolegamic +appropriation +archididascalian +archival +arteriophlebotomy +articulable +asseveration +assignation +atelo +atrienses +atrophy +atterminement +atypic +automower +aveloz +awrist +azteca +bairnteam +balsamweed +bannerman +beardy +becry +beek +beggarwise +bescab +bestness +bethel +bewildering +bibliophilism +bitterblain +blakeberyed +boccarella +bocedization +boobyalla +bourbon +bowbent +bowerbird +brachygnathous +brail +branchiferous +brelaw +brew +brideweed +bridgeable +brombenzamide +buddler +burbankian +burr +buskin +cacochymical +calefactory +caliper +canaliculus +candidature +canellaceous +canniness +canning +cantilene +carbonatation +carthamic +caseum +caudated +causationist +ceruleite +chalder +chalta +charmel +chekan +chillness +chirogymnast +chirpling +chlorinous +cholanthrene +chondroblast +chromatography +chromophilous +chronical +cicatrice +cinchonine +city +clubbing +coastal +coaxially +coercible +coeternity +coff +coinventor +collyba +combinator +complanation +comprehensibility +conchuela +congenital +context +contranatural +corallum +cordately +cornupete +corolliferous +coroneted +corticosterone +coseat +cottage +crocetin +crossleted +crottels +curvedness +cycadeous +cyclism +cylindrically +cynanche +cyrtoceratitic +cystospasm +danceress +dancette +dawny +daydreamy +debar +decarburization +decorousness +decrepitness +delirious +deozonizer +dermatosis +desma +deutencephalic +diacetate +diarthrodial +diathermy +dicolic +dimastigate +dimidiation +dipetto +disavowable +disintrench +disman +dismay +disorder +disoxygenation +dithionous +dogman +dragonfly +dramatical +drawspan +drubbly +drunk +duskly +ecderonic +ectocuniform +ectocyst +ehrwaldite +electrocute +elemicin +embracing +emotionality +enactment +enamor +enclave +endameba +endochylous +endocrinologist +endolymph +endothecal +entasia +epigeous +episcopicide +epitrichial +erminee +erraticalness +eruptivity +erythrocytoschisis +esperance +estuous +eucrystalline +eugeny +evacuant +everbloomer +evocation +exarchateship +exasperate +excorticate +excrementary +exile +expandedly +exponency +expressionist +expulsion +extemporary +extollation +extortive +extrabulbar +extraprostatic +facticide +fairer +fakery +fasibitikite +fatiscent +fearless +febrifuge +ferie +fibrousness +fingered +fisheye +flagpole +flagrantness +fleche +fluidism +folliculin +footbreadth +forceps +forecontrive +forthbring +foveated +fuchsin +fungicidal +funori +gamelang +gametically +garvanzo +gasoliner +gastrophile +germproof +gerontism +gigantical +glaciology +godmotherhood +gooseherd +gordunite +gove +gracilis +greathead +grieveship +guidable +gyromancy +gyrostat +habitus +hailweed +handhole +hangalai +haznadar +heliced +hemihypertrophy +hemimorphic +hemistrumectomy +heptavalent +heptite +herbalist +herpetology +hesperid +hexacarbon +hieromnemon +hobbyless +holodactylic +homoeoarchy +hopperings +hospitable +houseboat +huh +huntedly +hydroponics +hydrosomal +hyperdactylia +hyperperistalsis +hypogeocarpous +ideogram +idiopathical +illegitimate +imambarah +impotently +improvise +impuberal +inaccurately +incarnant +inchoation +incliner +incredulous +indiscriminateness +indulgenced +inebriation +inexpressiveness +infibulate +inflectedness +iniome +ink +inquietly +insaturable +insinuative +instiller +institutive +insultproof +interactionist +intercensal +interpenetrable +intertranspicuous +intrinsicality +inwards +iridiocyte +iridoparalysis +irreportable +isoprene +isosmotic +izard +jacuaru +jaculative +jerkined +joe +joyous +julienne +justicehood +kali +kalidium +katha +kathal +keelage +keratomycosis +khaki +khedival +kinkily +knife +kolo +kraken +kwarta +labba +labber +laboress +lacunar +latch +lauric +lawter +lectotype +leeches +legible +lepidosteoid +leucobasalt +leverer +libellate +limnimeter +lithography +lithotypic +locomotor +logarithmetically +logistician +lyncine +lysogenesis +machan +macromyelon +maharana +mandibulate +manganapatite +marchpane +mas +masochistic +mastaba +matching +meditatively +megalopolitan +melaniline +mentum +mercaptides +mestome +metasomatism +meterless +micronuclear +micropetalous +microreaction +microsporophore +mileway +milliarium +millisecond +misbind +miscollocation +misreader +modernicide +modification +modulant +monkfish +monoamino +monocarbide +monographical +morphinomaniac +mullein +munge +mutilate +mycophagist +myelosarcoma +myospasm +myriadly +nagaika +naphthionate +natant +naviculaeform +nayward +neallotype +necrophilia +nectared +neigher +neogamous +neurodynia +neurorthopteran +nidation +nieceship +nitrobacteria +nitrosification +nogheaded +nonassertive +noneuphonious +nonextant +nonincrease +nonintermittent +nonmetallic +nonprehensile +nonremunerative +nonsocial +nonvesting +noontime +noreaster +nounal +nub +nucleoplasm +nullisome +numero +numerous +oblongatal +observe +obtusilingual +obvert +occipitoatlantal +oceanside +ochlophobist +odontiasis +opalescence +opticon +oraculousness +orarium +organically +orthopedically +ostosis +overadvance +overbuilt +overdiscouragement +overdoer +overhardy +overjocular +overmagnify +overofficered +overpotent +overprizer +overrunner +overshrink +oversimply +oversplash +ovology +oxskin +oxychloride +oxygenant +ozokerite +pactional +palaeoanthropography +palaeographical +palaeopsychology +palliasse +palpebral +pandaric +pantelegraph +papicolist +papulate +parakinetic +parasitism +parochialic +parochialize +passionlike +patch +paucidentate +pawnbrokeress +pecite +pecky +pedipulation +pellitory +perfilograph +periblast +perigemmal +periost +periplus +perishable +periwig +permansive +persistingly +persymmetrical +phantom +phasmatrope +philocaly +philogyny +philosophister +philotherianism +phorology +phototrophic +phrator +phratral +phthisipneumony +physogastry +phytologic +phytoptid +pianograph +picqueter +piculet +pigeoner +pimaric +pinesap +pist +planometer +platano +playful +plea +pleuropneumonic +plowwoman +plump +pluviographical +pneumocele +podophthalmate +polyad +polythalamian +poppyhead +portamento +portmanteau +portraitlike +possible +potassamide +powderer +praepubis +preanesthetic +prebarbaric +predealer +predomination +prefactory +preirrigational +prelector +presbytership +presecure +preservable +prespecialist +preventionism +prewound +princely +priorship +proannexationist +proanthropos +probeable +probouleutic +profitless +proplasma +prosectorial +protecting +protochemistry +protosulphate +pseudoataxia +psilology +psychoneurotic +pterygial +publicist +purgation +purplishness +putatively +pyracene +pyrenomycete +pyromancy +pyrophone +quadroon +quailhead +qualifier +quaternal +rabblelike +rambunctious +rapidness +ratably +rationalism +razor +reannoy +recultivation +regulable +reimplant +reimposition +reimprison +reinjure +reinspiration +reintroduce +remantle +reprehensibility +reptant +require +resteal +restful +returnability +revisableness +rewash +rewhirl +reyield +rhizotomy +rhodamine +rigwiddie +rimester +ripper +rippet +rockish +rockwards +rollicky +roosters +rooted +rosal +rozum +saccharated +sagamore +sagy +salesmanship +salivous +sallet +salta +saprostomous +satiation +sauropsid +sawarra +sawback +scabish +scabrate +scampavia +scientificophilosophical +scirrosity +scoliometer +scolopendrelloid +secantly +seignioral +semibull +semic +seminarianism +semiped +semiprivate +semispherical +semispontaneous +seneschal +septendecimal +serotherapist +servation +sesquisulphuret +severish +sextipartite +sextubercular +shipyard +shuckpen +siderosis +silex +sillyhow +silverbelly +silverbelly +simulacrum +sisham +sixte +skeiner +skiapod +slopped +slubby +smalts +sockmaker +solute +somethingness +somnify +southwester +spathilla +spectrochemical +sphagnology +spinales +spiriting +spirling +spirochetemia +spreadboard +spurflower +squawdom +squeezing +staircase +staker +stamphead +statolith +stekan +stellulate +stinker +stomodaea +streamingly +strikingness +strouthocamelian +stuprum +subacutely +subboreal +subcontractor +subendorsement +subprofitable +subserviate +subsneer +subungual +sucuruju +sugan +sulphocarbolate +summerwood +superficialist +superinference +superregenerative +supplicate +suspendible +synchronizer +syntectic +tachyglossate +tailless +taintment +takingly +taletelling +tarpon +tasteful +taxeater +taxy +teache +teachless +teg +tegmen +teletyper +temperable +ten +tenent +teskere +testes +thallogen +thapsia +thewness +thickety +thiobacteria +thorniness +throwing +thyroprivic +tinnitus +tocalote +tolerationist +tonalamatl +torvous +totality +tottering +toug +tracheopathia +tragedical +translucent +trifoveolate +trilaurin +trophoplasmatic +trunkless +turbanless +turnpiker +twangle +twitterboned +ultraornate +umbilication +unabatingly +unabjured +unadequateness +unaffectedness +unarriving +unassorted +unattacked +unbenumbed +unboasted +unburning +uncensorious +uncongested +uncontemnedly +uncontemporary +uncrook +uncrystallizability +uncurb +uncustomariness +underbillow +undercanopy +underestimation +underhanging +underpetticoated +underpropped +undersole +understocking +underworld +undevout +undisappointing +undistinctive +unfiscal +unfluted +unfreckled +ungentilize +unglobe +unhelped +unhomogeneously +unifoliate +uninflammable +uninterrogated +unisonal +unkindled +unlikeableness +unlisty +unlocked +unmoving +unmultipliable +unnestled +unnoticed +unobservable +unobviated +unoffensively +unofficerlike +unpoetic +unpractically +unquestionableness +unrehearsed +unrevised +unrhetorical +unsadden +unsaluting +unscriptural +unseeking +unshowed +unsolicitous +unsprouted +unsubjective +unsubsidized +unsymbolic +untenant +unterrified +untranquil +untraversed +untrusty +untying +unwillful +unwinding +upspring +uptwist +urachovesical +uropygial +vagabondism +varicoid +varletess +vasal +ventrocaudal +verisimilitude +vermigerous +vibrometer +viminal +virus +vocationalism +voguey +vulnerability +waggle +wamblingly +warmus +waxer +waying +wedgeable +wellmaker +whomever +wigged +witchlike +wokas +woodrowel +woodsman +woolding +xanthelasmic +xiphosternum +yachtman +yachtsmanlike +yelp +zoophytal
\ No newline at end of file diff --git a/tests/phpunit/includes/cache/GenderCacheTest.php b/tests/phpunit/includes/cache/GenderCacheTest.php new file mode 100644 index 00000000..ce2db5d7 --- /dev/null +++ b/tests/phpunit/includes/cache/GenderCacheTest.php @@ -0,0 +1,104 @@ +<?php + +/** + * @group Database + * @group Cache + */ +class GenderCacheTest extends MediaWikiLangTestCase { + + protected function setUp() { + global $wgDefaultUserOptions; + parent::setUp(); + //ensure the correct default gender + $wgDefaultUserOptions['gender'] = 'unknown'; + } + + function addDBData() { + $user = User::newFromName( 'UTMale' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTMalePassword' ); + } + //ensure the right gender + $user->setOption( 'gender', 'male' ); + $user->saveSettings(); + + $user = User::newFromName( 'UTFemale' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTFemalePassword' ); + } + //ensure the right gender + $user->setOption( 'gender', 'female' ); + $user->saveSettings(); + + $user = User::newFromName( 'UTDefaultGender' ); + if ( $user->getID() == 0 ) { + $user->addToDatabase(); + $user->setPassword( 'UTDefaultGenderPassword' ); + } + //ensure the default gender + $user->setOption( 'gender', null ); + $user->saveSettings(); + } + + /** + * test usernames + * + * @dataProvider provideUserGenders + * @covers GenderCache::getGenderOf + */ + public function testUserName( $username, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $gender = $genderCache->getGenderOf( $username ); + $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); + } + + /** + * genderCache should work with user objects, too + * + * @dataProvider provideUserGenders + * @covers GenderCache::getGenderOf + */ + public function testUserObjects( $username, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $user = User::newFromName( $username ); + $gender = $genderCache->getGenderOf( $user ); + $this->assertEquals( $gender, $expectedGender, "GenderCache normal" ); + } + + public static function provideUserGenders() { + return array( + array( 'UTMale', 'male' ), + array( 'UTFemale', 'female' ), + array( 'UTDefaultGender', 'unknown' ), + array( 'UTNotExist', 'unknown' ), + //some not valid user + array( '127.0.0.1', 'unknown' ), + array( 'user@test', 'unknown' ), + ); + } + + /** + * test strip of subpages to avoid unnecessary queries + * against the never existing username + * + * @dataProvider provideStripSubpages + * @covers GenderCache::getGenderOf + */ + public function testStripSubpages( $pageWithSubpage, $expectedGender ) { + $genderCache = GenderCache::singleton(); + $gender = $genderCache->getGenderOf( $pageWithSubpage ); + $this->assertEquals( $gender, $expectedGender, "GenderCache must strip of subpages" ); + } + + public static function provideStripSubpages() { + return array( + array( 'UTMale/subpage', 'male' ), + array( 'UTFemale/subpage', 'female' ), + array( 'UTDefaultGender/subpage', 'unknown' ), + array( 'UTNotExist/subpage', 'unknown' ), + array( '127.0.0.1/subpage', 'unknown' ), + ); + } +} diff --git a/tests/phpunit/includes/cache/LocalisationCacheTest.php b/tests/phpunit/includes/cache/LocalisationCacheTest.php new file mode 100644 index 00000000..fc06a501 --- /dev/null +++ b/tests/phpunit/includes/cache/LocalisationCacheTest.php @@ -0,0 +1,91 @@ +<?php +/** + * @group Database + * @group Cache + * @covers LocalisationCache + * @author Niklas Laxström + */ +class LocalisationCacheTest extends MediaWikiTestCase { + protected function setUp() { + global $IP; + + parent::setUp(); + $this->setMwGlobals( array( + 'wgMessagesDirs' => array( "$IP/tests/phpunit/data/localisationcache" ), + 'wgExtensionMessagesFiles' => array(), + 'wgHooks' => array(), + ) ); + } + + public function testPuralRulesFallback() { + $cache = new LocalisationCache( array( 'store' => 'detect' ) ); + + $this->assertEquals( + $cache->getItem( 'ar', 'pluralRules' ), + $cache->getItem( 'arz', 'pluralRules' ), + 'arz plural rules (undefined) fallback to ar (defined)' + ); + + $this->assertEquals( + $cache->getItem( 'ar', 'compiledPluralRules' ), + $cache->getItem( 'arz', 'compiledPluralRules' ), + 'arz compiled plural rules (undefined) fallback to ar (defined)' + ); + + $this->assertNotEquals( + $cache->getItem( 'ksh', 'pluralRules' ), + $cache->getItem( 'de', 'pluralRules' ), + 'ksh plural rules (defined) dont fallback to de (defined)' + ); + + $this->assertNotEquals( + $cache->getItem( 'ksh', 'compiledPluralRules' ), + $cache->getItem( 'de', 'compiledPluralRules' ), + 'ksh compiled plural rules (defined) dont fallback to de (defined)' + ); + } + + public function testRecacheFallbacks() { + $lc = new LocalisationCache( array( 'store' => 'detect' ) ); + $lc->recache( 'uk' ); + $this->assertEquals( + array( + 'present-uk' => 'uk', + 'present-ru' => 'ru', + 'present-en' => 'en', + ), + $lc->getItem( 'uk', 'messages' ), + 'Fallbacks are only used to fill missing data' + ); + } + + public function testRecacheFallbacksWithHooks() { + global $wgHooks; + + // Use hook to provide updates for messages. This is what the + // LocalisationUpdate extension does. See bug 68781. + $wgHooks['LocalisationCacheRecacheFallback'][] = function ( + LocalisationCache $lc, + $code, + array &$cache + ) { + if ( $code === 'ru' ) { + $cache['messages']['present-uk'] = 'ru-override'; + $cache['messages']['present-ru'] = 'ru-override'; + $cache['messages']['present-en'] = 'ru-override'; + } + }; + + $lc = new LocalisationCache( array( 'store' => 'detect' ) ); + $lc->recache( 'uk' ); + $this->assertEquals( + array( + 'present-uk' => 'uk', + 'present-ru' => 'ru-override', + 'present-en' => 'ru-override', + ), + $lc->getItem( 'uk', 'messages' ), + 'Updates provided by hooks follow the normal fallback order.' + ); + } +} diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php new file mode 100644 index 00000000..442e9f9f --- /dev/null +++ b/tests/phpunit/includes/cache/MessageCacheTest.php @@ -0,0 +1,128 @@ +<?php + +/** + * @group Database + * @group Cache + * @covers MessageCache + */ +class MessageCacheTest extends MediaWikiLangTestCase { + + protected function setUp() { + parent::setUp(); + $this->configureLanguages(); + MessageCache::singleton()->enable(); + } + + /** + * Helper function -- setup site language for testing + */ + protected function configureLanguages() { + // for the test, we need the content language to be anything but English, + // let's choose e.g. German (de) + $langCode = 'de'; + $langObj = Language::factory( $langCode ); + + $this->setMwGlobals( array( + 'wgLanguageCode' => $langCode, + 'wgLang' => $langObj, + 'wgContLang' => $langObj, + ) ); + } + + function addDBData() { + $this->configureLanguages(); + + // Set up messages and fallbacks ab -> ru -> de + $this->makePage( 'FallbackLanguageTest-Full', 'ab' ); + $this->makePage( 'FallbackLanguageTest-Full', 'ru' ); + $this->makePage( 'FallbackLanguageTest-Full', 'de' ); + + // Fallbacks where ab does not exist + $this->makePage( 'FallbackLanguageTest-Partial', 'ru' ); + $this->makePage( 'FallbackLanguageTest-Partial', 'de' ); + + // Fallback to the content language + $this->makePage( 'FallbackLanguageTest-ContLang', 'de' ); + + // Add customizations for an existing message. + $this->makePage( 'sunday', 'ru' ); + + // Full key tests -- always want russian + $this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' ); + $this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' ); + + // In content language -- get base if no derivative + $this->makePage( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none', false ); + } + + /** + * Helper function for addDBData -- adds a simple page to the database + * + * @param string $title Title of page to be created + * @param string $lang Language and content of the created page + * @param string|null $content Content of the created page, or null for a generic string + * @param bool $createSubPage Set to false if a root page should be created + */ + protected function makePage( $title, $lang, $content = null, $createSubPage = true ) { + global $wgContLang; + + if ( $content === null ) { + $content = $lang; + } + if ( $lang !== $wgContLang->getCode() || $createSubPage ) { + $title = "$title/$lang"; + } + + $title = Title::newFromText( $title, NS_MEDIAWIKI ); + $wikiPage = new WikiPage( $title ); + $contentHandler = ContentHandler::makeContent( $content, $title ); + $wikiPage->doEditContent( $contentHandler, "$lang translation test case" ); + } + + /** + * Test message fallbacks, bug #1495 + * + * @dataProvider provideMessagesForFallback + */ + public function testMessageFallbacks( $message, $lang, $expectedContent ) { + $result = MessageCache::singleton()->get( $message, true, $lang ); + $this->assertEquals( $expectedContent, $result, "Message fallback failed." ); + } + + function provideMessagesForFallback() { + return array( + array( 'FallbackLanguageTest-Full', 'ab', 'ab' ), + array( 'FallbackLanguageTest-Partial', 'ab', 'ru' ), + array( 'FallbackLanguageTest-ContLang', 'ab', 'de' ), + array( 'FallbackLanguageTest-None', 'ab', false ), + + // Existing message with customizations on the fallbacks + array( 'sunday', 'ab', 'амҽыш' ), + + // bug 46579 + array( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' ), + // UI language different from content language should only use de/none as last option + array( 'FallbackLanguageTest-NoDervContLang', 'fit', 'de/none' ), + ); + } + + /** + * There's a fallback case where the message key is given as fully qualified -- this + * should ignore the passed $lang and use the language from the key + * + * @dataProvider provideMessagesForFullKeys + */ + public function testFullKeyBehaviour( $message, $lang, $expectedContent ) { + $result = MessageCache::singleton()->get( $message, true, $lang, true ); + $this->assertEquals( $expectedContent, $result, "Full key message fallback failed." ); + } + + function provideMessagesForFullKeys() { + return array( + array( 'MessageCacheTest-FullKeyTest/ru', 'ru', 'ru' ), + array( 'MessageCacheTest-FullKeyTest/ru', 'ab', 'ru' ), + array( 'MessageCacheTest-FullKeyTest/ru/foo', 'ru', false ), + ); + } + +} diff --git a/tests/phpunit/includes/cache/RedisBloomCacheTest.php b/tests/phpunit/includes/cache/RedisBloomCacheTest.php new file mode 100644 index 00000000..3d491e90 --- /dev/null +++ b/tests/phpunit/includes/cache/RedisBloomCacheTest.php @@ -0,0 +1,71 @@ +<?php + +/** + * Test for BloomCacheRedis class. + * + * @TODO: some generic base "redis test server conf" for all testing? + * + * @covers BloomCacheRedis + * @group Cache + */ +class BloomCacheRedisTest extends MediaWikiTestCase { + private static $suffix; + + protected function setUp() { + parent::setUp(); + + self::$suffix = self::$suffix ? : mt_rand(); + + $fcache = BloomCache::get( 'main' ); + if ( $fcache instanceof BloomCacheRedis ) { + $fcache->delete( "unit-testing-" . self::$suffix ); + } else { + $this->markTestSkipped( 'The main bloom cache is not redis.' ); + } + } + + public function testBloomCache() { + $key = "unit-testing-" . self::$suffix; + $fcache = BloomCache::get( 'main' ); + $count = 1500; + + $this->assertTrue( $fcache->delete( $key ), "OK delete of filter '$key'." ); + $this->assertTrue( $fcache->init( $key, $count, .001 ), "OK init of filter '$key'." ); + + $members = array(); + for ( $i = 0; $i < $count; ++$i ) { + $members[] = "$i-value-$i"; + } + $this->assertTrue( $fcache->add( $key, $members ), "Addition of members to '$key' OK." ); + + for ( $i = 0; $i < $count; ++$i ) { + $this->assertTrue( $fcache->isHit( $key, "$i-value-$i" ), "Hit on member '$i-value-$i'." ); + } + + $falsePositives = array(); + for ( $i = $count; $i < 2 * $count; ++$i ) { + if ( $fcache->isHit( $key, "value$i" ) ) { + $falsePositives[] = "value$i"; + } + } + + $eFalsePositives = array( + 'value1763', + 'value2245', + 'value2353', + 'value2791', + 'value2898', + 'value2975' + ); + $this->assertEquals( $eFalsePositives, $falsePositives, "Correct number of false positives found." ); + } + + protected function tearDown() { + parent::tearDown(); + + $fcache = BloomCache::get( 'main' ); + if ( $fcache instanceof BloomCacheRedis ) { + $fcache->delete( "unit-testing-" . self::$suffix ); + } + } +} diff --git a/tests/phpunit/includes/changes/EnhancedChangesListTest.php b/tests/phpunit/includes/changes/EnhancedChangesListTest.php new file mode 100644 index 00000000..40a11d2d --- /dev/null +++ b/tests/phpunit/includes/changes/EnhancedChangesListTest.php @@ -0,0 +1,132 @@ +<?php + +/** + * @covers EnhancedChangesList + * + * @group Database + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class EnhancedChangesListTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + public function testBeginRecentChangesList_styleModules() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $styleModules = $enhancedChangesList->getOutput()->getModuleStyles(); + + $this->assertContains( + 'mediawiki.special.changeslist', + $styleModules, + 'has mediawiki.special.changeslist' + ); + + $this->assertContains( + 'mediawiki.special.changeslist.enhanced', + $styleModules, + 'has mediawiki.special.changeslist.enhanced' + ); + } + + public function testBeginRecentChangesList_jsModules() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $modules = $enhancedChangesList->getOutput()->getModules(); + + $this->assertContains( 'jquery.makeCollapsible', $modules, 'has jquery.makeCollapsible' ); + $this->assertContains( 'mediawiki.icon', $modules, 'has mediawiki.icon' ); + } + + public function testBeginRecentChangesList_html() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $html = $enhancedChangesList->beginRecentChangesList(); + + $this->assertEquals( '<div class="mw-changeslist">', $html ); + } + + /** + * @todo more tests + */ + public function testRecentChangesLine() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $recentChange = $this->getEditChange( '20131103092153' ); + $html = $enhancedChangesList->recentChangesLine( $recentChange, false ); + + $this->assertInternalType( 'string', $html ); + + $recentChange2 = $this->getEditChange( '20131103092253' ); + $html = $enhancedChangesList->recentChangesLine( $recentChange2, false ); + + $this->assertEquals( '', $html ); + } + + /** + * @todo more tests for actual formatting, this is more of a smoke test + */ + public function testEndRecentChangesList() { + $enhancedChangesList = $this->newEnhancedChangesList(); + $enhancedChangesList->beginRecentChangesList(); + + $recentChange = $this->getEditChange( '20131103092153' ); + $enhancedChangesList->recentChangesLine( $recentChange, false ); + + $recentChange2 = $this->getEditChange( '20131103092253' ); + $enhancedChangesList->recentChangesLine( $recentChange2, false ); + + $html = $enhancedChangesList->endRecentChangesList(); + + preg_match_all( '/td class="mw-enhanced-rc-nested"/', $html, $matches ); + $this->assertCount( 2, $matches[0] ); + } + + /** + * @return EnhancedChangesList + */ + private function newEnhancedChangesList() { + $user = User::newFromId( 0 ); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + + return new EnhancedChangesList( $context ); + } + + /** + * @return RecentChange + */ + private function getEditChange( $timestamp ) { + $user = $this->getTestUser(); + $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( + $user, 'Cat', $timestamp, 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + /** + * @return User + */ + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + +} diff --git a/tests/phpunit/includes/changes/OldChangesListTest.php b/tests/phpunit/includes/changes/OldChangesListTest.php new file mode 100644 index 00000000..2ea9f33e --- /dev/null +++ b/tests/phpunit/includes/changes/OldChangesListTest.php @@ -0,0 +1,187 @@ +<?php + +/** + * @covers OldChangesList + * + * @todo add tests to cover article link, timestamp, character difference, + * log entry, user tool links, direction marks, tags, rollback, + * watching users, and date header. + * + * @group Database + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class OldChangesListTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1', + 'wgLang' => Language::factory( 'qqx' ) + ) ); + } + + /** + * @dataProvider recentChangesLine_CssForLineNumberProvider + */ + public function testRecentChangesLine_CssForLineNumber( $expected, $linenumber, $message ) { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, $linenumber ); + + $this->assertRegExp( $expected, $line, $message ); + } + + public function recentChangesLine_CssForLineNumberProvider() { + return array( + array( '/mw-line-odd/', 1, 'odd line number' ), + array( '/mw-line-even/', 2, 'even line number' ) + ); + } + + public function testRecentChangesLine_NotWatchedCssClass() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/mw-changeslist-line-not-watched/', $line ); + } + + public function testRecentChangesLine_WatchedCssClass() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, true, 1 ); + + $this->assertRegExp( '/mw-changeslist-line-watched/', $line ); + } + + public function testRecentChangesLine_LogTitle() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getLogChange( 'delete', 'delete' ); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/href="\/wiki\/Special:Log\/delete/', $line, 'link has href attribute' ); + $this->assertRegExp( '/title="Special:Log\/delete/', $line, 'link has title attribute' ); + $this->assertRegExp( "/dellogpage/", $line, 'link text' ); + } + + public function testRecentChangesLine_DiffHistLinks() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( + '/title=Cat&curid=20131103212153&diff=5&oldid=191/', + $line, + 'assert diff link' + ); + + $this->assertRegExp( '/tabindex="0"/', $line, 'assert tab index' ); + $this->assertRegExp( + '/title=Cat&curid=20131103212153&action=history"/', + $line, + 'assert history link' + ); + } + + public function testRecentChangesLine_Flags() { + $oldChangesList = $this->getOldChangesList(); + $recentChange = $this->getNewBotEditChange(); + + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertContains( + "<abbr class='newpage' title='(recentchanges-label-newpage)'>(newpageletter)</abbr>", + $line, + 'new page flag' + ); + + $this->assertContains( + "<abbr class='botedit' title='(recentchanges-label-bot)'>(boteditletter)</abbr>", + $line, + 'bot flag' + ); + } + + public function testRecentChangesLine_Tags() { + $recentChange = $this->getEditChange(); + $recentChange->mAttribs['ts_tags'] = 'vandalism,newbie'; + + $oldChangesList = $this->getOldChangesList(); + $line = $oldChangesList->recentChangesLine( $recentChange, false, 1 ); + + $this->assertRegExp( '/<li class="[\w\s-]*mw-tag-vandalism[\w\s-]*">/', $line ); + $this->assertRegExp( '/<li class="[\w\s-]*mw-tag-newbie[\w\s-]*">/', $line ); + } + + private function getNewBotEditChange() { + $user = $this->getTestUser(); + + $recentChange = $this->testRecentChangesHelper->makeNewBotEditRecentChange( + $user, 'Abc', '20131103212153', 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + private function getLogChange( $logType, $logAction ) { + $user = $this->getTestUser(); + + $recentChange = $this->testRecentChangesHelper->makeLogRecentChange( + $logType, $logAction, $user, 'Abc', '20131103212153', 0, 0 + ); + + return $recentChange; + } + + private function getEditChange() { + $user = $this->getTestUser(); + $recentChange = $this->testRecentChangesHelper->makeEditRecentChange( + $user, 'Cat', '20131103212153', 5, 191, 190, 0, 0 + ); + + return $recentChange; + } + + private function getOldChangesList() { + $context = $this->getContext(); + return new OldChangesList( $context ); + } + + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + + private function getContext() { + $user = $this->getTestUser(); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + $context->setLanguage( Language::factory( 'qqx' ) ); + + return $context; + } + +} diff --git a/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php new file mode 100644 index 00000000..ee1a4d0e --- /dev/null +++ b/tests/phpunit/includes/changes/RCCacheEntryFactoryTest.php @@ -0,0 +1,331 @@ +<?php + +/** + * @covers RCCacheEntryFactory + * + * @group Database + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class RCCacheEntryFactoryTest extends MediaWikiLangTestCase { + + /** + * @var TestRecentChangesHelper + */ + private $testRecentChangesHelper; + + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->testRecentChangesHelper = new TestRecentChangesHelper(); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgArticlePath' => '/wiki/$1' + ) ); + } + + /** + * @dataProvider editChangeProvider + */ + public function testNewFromRecentChange( $expected, $context, $messages, + $recentChange, $watched + ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], $cacheEntry->numberofWatchingusers, + 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + $this->assertTitleLink( 'Xyz', $cacheEntry ); + + $this->assertQueryLink( 'cur', $expected['cur'], $cacheEntry->curlink, 'cur link' ); + $this->assertQueryLink( 'prev', $expected['diff'], $cacheEntry->lastlink, 'prev link' ); + $this->assertQueryLink( 'diff', $expected['diff'], $cacheEntry->difflink, 'diff link' ); + } + + public function editChangeProvider() { + return array( + array( + array( + 'title' => 'Xyz', + 'user' => 'TestRecentChangesUser', + 'diff' => array( 'curid' => 5, 'diff' => 191, 'oldid' => 190 ), + 'cur' => array( 'curid' => 5, 'diff' => 0, 'oldid' => 191 ), + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeEditRecentChange( + $this->getTestUser(), + 'Xyz', + 5, // curid + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + /** + * @dataProvider deleteChangeProvider + */ + public function testNewForDeleteChange( $expected, $context, $messages, $recentChange, $watched ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], + $cacheEntry->numberofWatchingusers, 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertDeleteLogLink( $cacheEntry ); + $this->assertUserLinks( 'TestRecentChangesUser', $cacheEntry ); + + $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' ); + $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' ); + $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); + } + + public function deleteChangeProvider() { + return array( + array( + array( + 'title' => 'Abc', + 'user' => 'TestRecentChangesUser', + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeLogRecentChange( + 'delete', + 'delete', + $this->getTestUser(), + 'Abc', + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + /** + * @dataProvider revUserDeleteProvider + */ + public function testNewForRevUserDeleteChange( $expected, $context, $messages, + $recentChange, $watched + ) { + $cacheEntryFactory = new RCCacheEntryFactory( $context, $messages ); + $cacheEntry = $cacheEntryFactory->newFromRecentChange( $recentChange, $watched ); + + $this->assertInstanceOf( 'RCCacheEntry', $cacheEntry ); + + $this->assertEquals( $watched, $cacheEntry->watched, 'watched' ); + $this->assertEquals( $expected['timestamp'], $cacheEntry->timestamp, 'timestamp' ); + $this->assertEquals( + $expected['numberofWatchingusers'], + $cacheEntry->numberofWatchingusers, 'watching users' + ); + $this->assertEquals( $expected['unpatrolled'], $cacheEntry->unpatrolled, 'unpatrolled' ); + + $this->assertRevDel( $cacheEntry ); + $this->assertTitleLink( 'Zzz', $cacheEntry ); + + $this->assertEquals( 'cur', $cacheEntry->curlink, 'cur link for delete log or rev' ); + $this->assertEquals( 'diff', $cacheEntry->difflink, 'diff link for delete log or rev' ); + $this->assertEquals( 'prev', $cacheEntry->lastlink, 'pref link for delete log or rev' ); + } + + public function revUserDeleteProvider() { + return array( + array( + array( + 'title' => 'Zzz', + 'user' => 'TestRecentChangesUser', + 'diff' => '', + 'cur' => '', + 'timestamp' => '21:21', + 'numberofWatchingusers' => 0, + 'unpatrolled' => false + ), + $this->getContext(), + $this->getMessages(), + $this->testRecentChangesHelper->makeDeletedEditRecentChange( + $this->getTestUser(), + 'Zzz', + '20131103212153', + 191, // thisid + 190, // lastid + '20131103212153', + 0, // counter + 0 // number of watching users + ), + false + ) + ); + } + + private function assertUserLinks( $user, $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'class' => 'new mw-userlink' + ), + 'content' => $user + ), + $cacheEntry->userlink, + 'verify user link' + ); + + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'mw-usertoollinks' + ), + 'child' => array( + 'tag' => 'a', + 'content' => 'Talk', + ) + ), + $cacheEntry->usertalklink, + 'verify user talk link' + ); + + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'mw-usertoollinks' + ), + 'child' => array( + 'tag' => 'a', + 'content' => 'contribs', + ) + ), + $cacheEntry->usertalklink, + 'verify user tool links' + ); + } + + private function assertDeleteLogLink( $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'href' => '/wiki/Special:Log/delete', + 'title' => 'Special:Log/delete' + ), + 'content' => 'Deletion log' + ), + $cacheEntry->link, + 'verify deletion log link' + ); + } + + private function assertRevDel( $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'span', + 'attributes' => array( + 'class' => 'history-deleted' + ), + 'content' => '(username removed)' + ), + $cacheEntry->userlink, + 'verify user link for change with deleted revision and user' + ); + } + + private function assertTitleLink( $title, $cacheEntry ) { + $this->assertTag( + array( + 'tag' => 'a', + 'attributes' => array( + 'href' => '/wiki/' . $title, + 'title' => $title + ), + 'content' => $title + ), + $cacheEntry->link, + 'verify title link' + ); + } + + private function assertQueryLink( $content, $params, $link ) { + $this->assertTag( + array( + 'tag' => 'a', + 'content' => $content + ), + $link, + 'assert query link element' + ); + + foreach ( $params as $key => $value ) { + $this->assertRegExp( '/' . $key . '=' . $value . '/', $link, "verify $key link params" ); + } + } + + private function getMessages() { + return array( + 'cur' => 'cur', + 'diff' => 'diff', + 'hist' => 'hist', + 'enhancedrc-history' => 'history', + 'last' => 'prev', + 'blocklink' => 'block', + 'history' => 'Page history', + 'semicolon-separator' => '; ', + 'pipe-separator' => ' | ' + ); + } + + private function getTestUser() { + $user = User::newFromName( 'TestRecentChangesUser' ); + + if ( !$user->getId() ) { + $user->addToDatabase(); + } + + return $user; + } + + private function getContext() { + $user = $this->getTestUser(); + $context = $this->testRecentChangesHelper->getTestContext( $user ); + + $title = Title::newFromText( 'RecentChanges', NS_SPECIAL ); + $context->setTitle( $title ); + + return $context; + } +} diff --git a/tests/phpunit/includes/changes/RecentChangeTest.php b/tests/phpunit/includes/changes/RecentChangeTest.php new file mode 100644 index 00000000..98903f1e --- /dev/null +++ b/tests/phpunit/includes/changes/RecentChangeTest.php @@ -0,0 +1,286 @@ +<?php + +/** + * @group Database + */ +class RecentChangeTest extends MediaWikiTestCase { + protected $title; + protected $target; + protected $user; + protected $user_comment; + protected $context; + + public function __construct() { + parent::__construct(); + + $this->title = Title::newFromText( 'SomeTitle' ); + $this->target = Title::newFromText( 'TestTarget' ); + $this->user = User::newFromName( 'UserName' ); + + $this->user_comment = '<User comment about action>'; + $this->context = RequestContext::newExtraneousContext( $this->title ); + } + + /** + * The testIrcMsgForAction* tests are supposed to cover the hacky + * LogFormatter::getIRCActionText / bug 34508 + * + * Third parties bots listen to those messages. They are clever enough + * to fetch the i18n messages from the wiki and then analyze the IRC feed + * to reverse engineer the $1, $2 messages. + * One thing bots can not detect is when MediaWiki change the meaning of + * a message like what happened when we deployed 1.19. $1 became the user + * performing the action which broke basically all bots around. + * + * Should cover the following log actions (which are most commonly used by bots): + * - block/block + * - block/unblock + * - delete/delete + * - delete/restore + * - newusers/create + * - newusers/create2 + * - newusers/autocreate + * - move/move + * - move/move_redir + * - protect/protect + * - protect/modifyprotect + * - protect/unprotect + * - upload/upload + * + * As well as the following Auto Edit Summaries: + * - blank + * - replace + * - rollback + * - undo + */ + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeBlock() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # block/block + $this->assertIRCComment( + $this->context->msg( 'blocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'block', 'block', + array(), + $this->user_comment + ); + # block/unblock + $this->assertIRCComment( + $this->context->msg( 'unblocklogentry', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'block', 'unblock', + array(), + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeDelete() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # delete/delete + $this->assertIRCComment( + $this->context->msg( 'deletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'delete', 'delete', + array(), + $this->user_comment + ); + + # delete/restore + $this->assertIRCComment( + $this->context->msg( 'undeletedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'delete', 'restore', + array(), + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeNewusers() { + $this->assertIRCComment( + 'New user account', + 'newusers', 'newusers', + array() + ); + $this->assertIRCComment( + 'New user account', + 'newusers', 'create', + array() + ); + $this->assertIRCComment( + 'created new account SomeTitle', + 'newusers', 'create2', + array() + ); + $this->assertIRCComment( + 'Account created automatically', + 'newusers', 'autocreate', + array() + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeMove() { + $move_params = array( + '4::target' => $this->target->getPrefixedText(), + '5::noredir' => 0, + ); + $sep = $this->context->msg( 'colon-separator' )->text(); + + # move/move + $this->assertIRCComment( + $this->context->msg( '1movedto2', 'SomeTitle', 'TestTarget' ) + ->plain() . $sep . $this->user_comment, + 'move', 'move', + $move_params, + $this->user_comment + ); + + # move/move_redir + $this->assertIRCComment( + $this->context->msg( '1movedto2_redir', 'SomeTitle', 'TestTarget' ) + ->plain() . $sep . $this->user_comment, + 'move', 'move_redir', + $move_params, + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypePatrol() { + # patrol/patrol + $this->assertIRCComment( + $this->context->msg( 'patrol-log-line', 'revision 777', '[[SomeTitle]]', '' )->plain(), + 'patrol', 'patrol', + array( + '4::curid' => '777', + '5::previd' => '666', + '6::auto' => 0, + ) + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeProtect() { + $protectParams = array( + '[edit=sysop] (indefinite) [move=sysop] (indefinite)' + ); + $sep = $this->context->msg( 'colon-separator' )->text(); + + # protect/protect + $this->assertIRCComment( + $this->context->msg( 'protectedarticle', 'SomeTitle ' . $protectParams[0] ) + ->plain() . $sep . $this->user_comment, + 'protect', 'protect', + $protectParams, + $this->user_comment + ); + + # protect/unprotect + $this->assertIRCComment( + $this->context->msg( 'unprotectedarticle', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'protect', 'unprotect', + array(), + $this->user_comment + ); + + # protect/modify + $this->assertIRCComment( + $this->context->msg( 'modifiedarticleprotection', 'SomeTitle ' . $protectParams[0] ) + ->plain() . $sep . $this->user_comment, + 'protect', 'modify', + $protectParams, + $this->user_comment + ); + } + + /** + * @covers LogFormatter::getIRCActionText + */ + public function testIrcMsgForLogTypeUpload() { + $sep = $this->context->msg( 'colon-separator' )->text(); + + # upload/upload + $this->assertIRCComment( + $this->context->msg( 'uploadedimage', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'upload', 'upload', + array(), + $this->user_comment + ); + + # upload/overwrite + $this->assertIRCComment( + $this->context->msg( 'overwroteimage', 'SomeTitle' )->plain() . $sep . $this->user_comment, + 'upload', 'overwrite', + array(), + $this->user_comment + ); + } + + /** + * @todo Emulate these edits somehow and extract + * raw edit summary from RecentChange object + * -- + */ + /* + public function testIrcMsgForBlankingAES() { + // $this->context->msg( 'autosumm-blank', .. ); + } + + public function testIrcMsgForReplaceAES() { + // $this->context->msg( 'autosumm-replace', .. ); + } + + public function testIrcMsgForRollbackAES() { + // $this->context->msg( 'revertpage', .. ); + } + + public function testIrcMsgForUndoAES() { + // $this->context->msg( 'undo-summary', .. ); + } + */ + + /** + * @param string $expected Expected IRC text without colors codes + * @param string $type Log type (move, delete, suppress, patrol ...) + * @param string $action A log type action + * @param array $params + * @param string $comment (optional) A comment for the log action + * @param string $msg (optional) A message for PHPUnit :-) + */ + protected function assertIRCComment( $expected, $type, $action, $params, + $comment = null, $msg = '' + ) { + $logEntry = new ManualLogEntry( $type, $action ); + $logEntry->setPerformer( $this->user ); + $logEntry->setTarget( $this->title ); + if ( $comment !== null ) { + $logEntry->setComment( $comment ); + } + $logEntry->setParameters( $params ); + + $formatter = LogFormatter::newFromEntry( $logEntry ); + $formatter->setContext( $this->context ); + + // Apply the same transformation as done in IRCColourfulRCFeedFormatter::getLine for rc_comment + $ircRcComment = IRCColourfulRCFeedFormatter::cleanupForIRC( $formatter->getIRCActionComment() ); + + $this->assertEquals( + $expected, + $ircRcComment, + $msg + ); + } +} diff --git a/tests/phpunit/includes/changes/TestRecentChangesHelper.php b/tests/phpunit/includes/changes/TestRecentChangesHelper.php new file mode 100644 index 00000000..ad643274 --- /dev/null +++ b/tests/phpunit/includes/changes/TestRecentChangesHelper.php @@ -0,0 +1,137 @@ +<?php + +/** + * Helper for generating test recent changes entries. + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class TestRecentChangesHelper { + + public function makeEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid, + $timestamp, $counter, $watchingUsers + ) { + + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid, + 'rc_cur_id' => $curid + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeLogRecentChange( $logType, $logAction, User $user, $titleText, $timestamp, $counter, + $watchingUsers + ) { + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_cur_id' => 0, + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => 0, + 'rc_last_oldid' => 0, + 'rc_old_len' => null, + 'rc_new_len' => null, + 'rc_type' => 3, + 'rc_logid' => 25, + 'rc_log_type' => $logType, + 'rc_log_action' => $logAction, + 'rc_source' => 'mw.log' + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeDeletedEditRecentChange( User $user, $titleText, $timestamp, $curid, + $thisid, $lastid, $counter, $watchingUsers + ) { + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_deleted' => 5, + 'rc_cur_id' => $curid, + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + public function makeNewBotEditRecentChange( User $user, $titleText, $curid, $thisid, $lastid, + $timestamp, $counter, $watchingUsers + ) { + + $attribs = array_merge( + $this->getDefaultAttributes( $titleText, $timestamp ), + array( + 'rc_user' => $user->getId(), + 'rc_user_text' => $user->getName(), + 'rc_this_oldid' => $thisid, + 'rc_last_oldid' => $lastid, + 'rc_cur_id' => $curid, + 'rc_type' => 1, + 'rc_bot' => 1, + 'rc_source' => 'mw.new' + ) + ); + + return $this->makeRecentChange( $attribs, $counter, $watchingUsers ); + } + + private function makeRecentChange( $attribs, $counter, $watchingUsers ) { + $change = new RecentChange(); + $change->setAttribs( $attribs ); + $change->counter = $counter; + $change->numberofWatchingusers = $watchingUsers; + + return $change; + } + + private function getDefaultAttributes( $titleText, $timestamp ) { + return array( + 'rc_id' => 545, + 'rc_user' => 0, + 'rc_user_text' => '127.0.0.1', + 'rc_ip' => '127.0.0.1', + 'rc_title' => $titleText, + 'rc_namespace' => 0, + 'rc_timestamp' => $timestamp, + 'rc_old_len' => 212, + 'rc_new_len' => 188, + 'rc_comment' => '', + 'rc_minor' => 0, + 'rc_bot' => 0, + 'rc_type' => 0, + 'rc_patrolled' => 1, + 'rc_deleted' => 0, + 'rc_logid' => 0, + 'rc_log_type' => null, + 'rc_log_action' => '', + 'rc_params' => '', + 'rc_source' => 'mw.edit' + ); + } + + public function getTestContext( User $user ) { + $context = new RequestContext(); + $context->setLanguage( Language::factory( 'en' ) ); + + $context->setUser( $user ); + + $title = Title::newFromText( 'RecentChanges', NS_SPECIAL ); + $context->setTitle( $title ); + + return $context; + } +} diff --git a/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php new file mode 100644 index 00000000..3f887dc0 --- /dev/null +++ b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php @@ -0,0 +1,161 @@ +<?php + +/** + * @covers ComposerVersionNormalizer + * + * @group ComposerHooks + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class ComposerVersionNormalizerTest extends PHPUnit_Framework_TestCase { + + /** + * @dataProvider nonStringProvider + */ + public function testGivenNonString_normalizeThrowsInvalidArgumentException( $nonString ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->setExpectedException( 'InvalidArgumentException' ); + $normalizer->normalizeSuffix( $nonString ); + } + + public function nonStringProvider() { + return array( + array( null ), + array( 42 ), + array( array() ), + array( new stdClass() ), + array( true ), + ); + } + + /** + * @dataProvider simpleVersionProvider + */ + public function testGivenSimpleVersion_normalizeSuffixReturnsAsIs( $simpleVersion ) { + $this->assertRemainsUnchanged( $simpleVersion ); + } + + protected function assertRemainsUnchanged( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeSuffix( $version ) + ); + } + + public function simpleVersionProvider() { + return array( + array( '1.22.0' ), + array( '1.19.2' ), + array( '1.19.2.0' ), + array( '1.9' ), + array( '123.321.456.654' ), + ); + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithoutDash_normalizeSuffixAddsDash( + $withoutDash, $withDash + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $withDash, + $normalizer->normalizeSuffix( $withoutDash ) + ); + } + + public function complexVersionProvider() { + return array( + array( '1.22.0alpha', '1.22.0-alpha' ), + array( '1.22.0RC', '1.22.0-RC' ), + array( '1.19beta', '1.19-beta' ), + array( '1.9RC4', '1.9-RC4' ), + array( '1.9.1.2RC4', '1.9.1.2-RC4' ), + array( '1.9.1.2RC', '1.9.1.2-RC' ), + array( '123.321.456.654RC9001', '123.321.456.654-RC9001' ), + ); + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithDash_normalizeSuffixReturnsAsIs( + $withoutDash, $withDash + ) { + $this->assertRemainsUnchanged( $withDash ); + } + + /** + * @dataProvider fourLevelVersionsProvider + */ + public function testGivenFourLevels_levelCountNormalizationDoesNothing( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function fourLevelVersionsProvider() { + return array( + array( '1.22.0.0' ), + array( '1.19.2.4' ), + array( '1.19.2.0' ), + array( '1.9.0.1' ), + array( '123.321.456.654' ), + array( '123.321.456.654RC4' ), + array( '123.321.456.654-RC4' ), + ); + } + + /** + * @dataProvider levelNormalizationProvider + */ + public function testGivenFewerLevels_levelCountNormalizationEnsuresFourLevels( + $expected, $version + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $expected, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function levelNormalizationProvider() { + return array( + array( '1.22.0.0', '1.22' ), + array( '1.22.0.0', '1.22.0' ), + array( '1.19.2.0', '1.19.2' ), + array( '12345.0.0.0', '12345' ), + array( '12345.0.0.0-RC4', '12345-RC4' ), + array( '12345.0.0.0-alpha', '12345-alpha' ), + ); + } + + /** + * @dataProvider invalidVersionProvider + */ + public function testGivenInvalidVersion_normalizeSuffixReturnsAsIs( $invalidVersion ) { + $this->assertRemainsUnchanged( $invalidVersion ); + } + + public function invalidVersionProvider() { + return array( + array( '1.221-a' ), + array( '1.221-' ), + array( '1.22rc4a' ), + array( 'a1.22rc' ), + array( '.1.22rc' ), + array( 'a' ), + array( 'alpha42' ), + ); + } +} diff --git a/tests/phpunit/includes/config/ConfigFactoryTest.php b/tests/phpunit/includes/config/ConfigFactoryTest.php new file mode 100644 index 00000000..3902858d --- /dev/null +++ b/tests/phpunit/includes/config/ConfigFactoryTest.php @@ -0,0 +1,70 @@ +<?php + +class ConfigFactoryTest extends MediaWikiTestCase { + + public function tearDown() { + // Reset this since we mess with it a bit + ConfigFactory::destroyDefaultInstance(); + parent::tearDown(); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegister() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( 'InvalidArgumentException' ); + $factory->register( 'invalid', 'Invalid callback' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfig() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( 'Config', $conf ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithNoBuilders() { + $factory = new ConfigFactory(); + $this->setExpectedException( 'ConfigException' ); + $factory->makeConfig( 'nobuilderregistered' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithInvalidCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', function () { + return true; // Not a Config object + } ); + $this->setExpectedException( 'UnexpectedValueException' ); + $factory->makeConfig( 'unittest' ); + } + + /** + * @covers ConfigFactory::getDefaultInstance + */ + public function testGetDefaultInstance() { + // Set $wgConfigRegistry, and check the default + // instance read from it + $this->setMwGlobals( 'wgConfigRegistry', array( + 'conf1' => 'GlobalVarConfig::newInstance', + 'conf2' => 'GlobalVarConfig::newInstance', + ) ); + ConfigFactory::destroyDefaultInstance(); + $factory = ConfigFactory::getDefaultInstance(); + $this->assertInstanceOf( 'Config', $factory->makeConfig( 'conf1' ) ); + $this->assertInstanceOf( 'Config', $factory->makeConfig( 'conf2' ) ); + $this->setExpectedException( 'ConfigException' ); + $factory->makeConfig( 'conf3' ); + } +} diff --git a/tests/phpunit/includes/config/GlobalVarConfigTest.php b/tests/phpunit/includes/config/GlobalVarConfigTest.php new file mode 100644 index 00000000..70b9e684 --- /dev/null +++ b/tests/phpunit/includes/config/GlobalVarConfigTest.php @@ -0,0 +1,120 @@ +<?php + +class GlobalVarConfigTest extends MediaWikiTestCase { + + /** + * @covers GlobalVarConfig::newInstance + */ + public function testNewInstance() { + $config = GlobalVarConfig::newInstance(); + $this->assertInstanceOf( 'GlobalVarConfig', $config ); + $this->maybeStashGlobal( 'wgBaz' ); + $GLOBALS['wgBaz'] = 'somevalue'; + // Check prefix is set to 'wg' + $this->assertEquals( 'somevalue', $config->get( 'Baz' ) ); + } + + /** + * @covers GlobalVarConfig::__construct + * @dataProvider provideConstructor + */ + public function testConstructor( $prefix ) { + $var = $prefix . 'GlobalVarConfigTest'; + $rand = wfRandomString(); + $this->maybeStashGlobal( $var ); + $GLOBALS[$var] = $rand; + $config = new GlobalVarConfig( $prefix ); + $this->assertInstanceOf( 'GlobalVarConfig', $config ); + $this->assertEquals( $rand, $config->get( 'GlobalVarConfigTest' ) ); + } + + public static function provideConstructor() { + return array( + array( 'wg' ), + array( 'ef' ), + array( 'smw' ), + array( 'blahblahblahblah' ), + array( '' ), + ); + } + + /** + * @covers GlobalVarConfig::has + */ + public function testHas() { + $this->maybeStashGlobal( 'wgGlobalVarConfigTestHas' ); + $GLOBALS['wgGlobalVarConfigTestHas'] = wfRandomString(); + $this->maybeStashGlobal( 'wgGlobalVarConfigTestNotHas' ); + $config = new GlobalVarConfig(); + $this->assertTrue( $config->has( 'GlobalVarConfigTestHas' ) ); + $this->assertFalse( $config->has( 'GlobalVarConfigTestNotHas' ) ); + } + + public static function provideGet() { + $set = array( + 'wgSomething' => 'default1', + 'wgFoo' => 'default2', + 'efVariable' => 'default3', + 'BAR' => 'default4', + ); + + foreach ( $set as $var => $value ) { + $GLOBALS[$var] = $value; + } + + return array( + array( 'Something', 'wg', 'default1' ), + array( 'Foo', 'wg', 'default2' ), + array( 'Variable', 'ef', 'default3' ), + array( 'BAR', '', 'default4' ), + array( 'ThisGlobalWasNotSetAbove', 'wg', false ) + ); + } + + /** + * @param string $name + * @param string $prefix + * @param string $expected + * @dataProvider provideGet + * @covers GlobalVarConfig::get + * @covers GlobalVarConfig::getWithPrefix + */ + public function testGet( $name, $prefix, $expected ) { + $config = new GlobalVarConfig( $prefix ); + if ( $expected === false ) { + $this->setExpectedException( 'ConfigException', 'GlobalVarConfig::get: undefined option:' ); + } + $this->assertEquals( $config->get( $name ), $expected ); + } + + public static function provideSet() { + return array( + array( 'Foo', 'wg', 'wgFoo' ), + array( 'SomethingRandom', 'wg', 'wgSomethingRandom' ), + array( 'FromAnExtension', 'eg', 'egFromAnExtension' ), + array( 'NoPrefixHere', '', 'NoPrefixHere' ), + ); + } + + private function maybeStashGlobal( $var ) { + if ( array_key_exists( $var, $GLOBALS ) ) { + // Will be reset after this test is over + $this->stashMwGlobals( $var ); + } + } + + /** + * @dataProvider provideSet + * @covers GlobalVarConfig::set + * @covers GlobalVarConfig::setWithPrefix + */ + public function testSet( $name, $prefix, $var ) { + $this->hideDeprecated( 'GlobalVarConfig::set' ); + $this->maybeStashGlobal( $var ); + $config = new GlobalVarConfig( $prefix ); + $random = wfRandomString(); + $config->set( $name, $random ); + $this->assertArrayHasKey( $var, $GLOBALS ); + $this->assertEquals( $random, $GLOBALS[$var] ); + } +} diff --git a/tests/phpunit/includes/config/HashConfigTest.php b/tests/phpunit/includes/config/HashConfigTest.php new file mode 100644 index 00000000..3ad3bfbd --- /dev/null +++ b/tests/phpunit/includes/config/HashConfigTest.php @@ -0,0 +1,63 @@ +<?php + +class HashConfigTest extends MediaWikiTestCase { + + /** + * @covers HashConfig::newInstance + */ + public function testNewInstance() { + $conf = HashConfig::newInstance(); + $this->assertInstanceOf( 'HashConfig', $conf ); + } + + /** + * @covers HashConfig::__construct + */ + public function testConstructor() { + $conf = new HashConfig(); + $this->assertInstanceOf( 'HashConfig', $conf ); + + // Test passing arguments to the constructor + $conf2 = new HashConfig( array( + 'one' => '1', + ) ); + $this->assertEquals( '1', $conf2->get( 'one' ) ); + } + + /** + * @covers HashConfig::get + */ + public function testGet() { + $conf = new HashConfig( array( + 'one' => '1', + )); + $this->assertEquals( '1', $conf->get( 'one' ) ); + $this->setExpectedException( 'ConfigException', 'HashConfig::get: undefined option' ); + $conf->get( 'two' ); + } + + /** + * @covers HashConfig::has + */ + public function testHas() { + $conf = new HashConfig( array( + 'one' => '1', + ) ); + $this->assertTrue( $conf->has( 'one' ) ); + $this->assertFalse( $conf->has( 'two' ) ); + } + + /** + * @covers HashConfig::set + */ + public function testSet() { + $conf = new HashConfig( array( + 'one' => '1', + ) ); + $conf->set( 'two', '2' ); + $this->assertEquals( '2', $conf->get( 'two' ) ); + // Check that set overwrites + $conf->set( 'one', '3' ); + $this->assertEquals( '3', $conf->get( 'one' ) ); + } +}
\ No newline at end of file diff --git a/tests/phpunit/includes/config/MultiConfigTest.php b/tests/phpunit/includes/config/MultiConfigTest.php new file mode 100644 index 00000000..158da466 --- /dev/null +++ b/tests/phpunit/includes/config/MultiConfigTest.php @@ -0,0 +1,38 @@ +<?php + +class MultiConfigTest extends MediaWikiTestCase { + + /** + * Tests that settings are fetched in the right order + * + * @covers MultiConfig::get + */ + public function testGet() { + $multi = new MultiConfig( array( + new HashConfig( array( 'foo' => 'bar' ) ), + new HashConfig( array( 'foo' => 'baz', 'bar' => 'foo' ) ), + new HashConfig( array( 'bar' => 'baz' ) ), + ) ); + + $this->assertEquals( 'bar', $multi->get( 'foo' ) ); + $this->assertEquals( 'foo', $multi->get( 'bar' ) ); + $this->setExpectedException( 'ConfigException', 'MultiConfig::get: undefined option:' ); + $multi->get( 'notset' ); + } + + /** + * @covers MultiConfig::has + */ + public function testHas() { + $conf = new MultiConfig( array( + new HashConfig( array( 'foo' => 'foo' ) ), + new HashConfig( array( 'something' => 'bleh' ) ), + new HashConfig( array( 'meh' => 'eh' ) ), + ) ); + + $this->assertTrue( $conf->has( 'foo' ) ); + $this->assertTrue( $conf->has( 'something' ) ); + $this->assertTrue( $conf->has( 'meh' ) ); + $this->assertFalse( $conf->has( 'what' ) ); + } +} diff --git a/tests/phpunit/includes/content/ContentHandlerTest.php b/tests/phpunit/includes/content/ContentHandlerTest.php new file mode 100644 index 00000000..f7449734 --- /dev/null +++ b/tests/phpunit/includes/content/ContentHandlerTest.php @@ -0,0 +1,525 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * + * @note Declare that we are using the database, because otherwise we'll fail in + * the "databaseless" test run. This is because the LinkHolderArray used by the + * parser needs database access. + */ +class ContentHandlerTest extends MediaWikiTestCase { + + protected function setUp() { + global $wgContLang; + parent::setUp(); + + $this->setMwGlobals( array( + 'wgExtraNamespaces' => array( + 12312 => 'Dummy', + 12313 => 'Dummy_talk', + ), + // The below tests assume that namespaces not mentioned here (Help, User, MediaWiki, ..) + // default to CONTENT_MODEL_WIKITEXT. + 'wgNamespaceContentModels' => array( + 12312 => 'testing', + ), + 'wgContentHandlers' => array( + CONTENT_MODEL_WIKITEXT => 'WikitextContentHandler', + CONTENT_MODEL_JAVASCRIPT => 'JavaScriptContentHandler', + CONTENT_MODEL_CSS => 'CssContentHandler', + CONTENT_MODEL_TEXT => 'TextContentHandler', + 'testing' => 'DummyContentHandlerForTesting', + ), + ) ); + + // Reset namespace cache + MWNamespace::getCanonicalNamespaces( true ); + $wgContLang->resetNamespaces(); + } + + protected function tearDown() { + global $wgContLang; + + // Reset namespace cache + MWNamespace::getCanonicalNamespaces( true ); + $wgContLang->resetNamespaces(); + + parent::tearDown(); + } + + public static function dataGetDefaultModelFor() { + return array( + array( 'Help:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'Help:Foo/bar.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo.js', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'User:Foo/bar.css', CONTENT_MODEL_CSS ), + array( 'User talk:Foo/bar.css', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.js.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'User:Foo/bar.xxx', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.js', CONTENT_MODEL_JAVASCRIPT ), + array( 'MediaWiki:Foo.css', CONTENT_MODEL_CSS ), + array( 'MediaWiki:Foo.JS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.CSS', CONTENT_MODEL_WIKITEXT ), + array( 'MediaWiki:Foo.css.xxx', CONTENT_MODEL_WIKITEXT ), + ); + } + + /** + * @dataProvider dataGetDefaultModelFor + * @covers ContentHandler::getDefaultModelFor + */ + public function testGetDefaultModelFor( $title, $expectedModelId ) { + $title = Title::newFromText( $title ); + $this->assertEquals( $expectedModelId, ContentHandler::getDefaultModelFor( $title ) ); + } + + /** + * @dataProvider dataGetDefaultModelFor + * @covers ContentHandler::getForTitle + */ + public function testGetForTitle( $title, $expectedContentModel ) { + $title = Title::newFromText( $title ); + $handler = ContentHandler::getForTitle( $title ); + $this->assertEquals( $expectedContentModel, $handler->getModelID() ); + } + + public static function dataGetLocalizedName() { + return array( + array( null, null ), + array( "xyzzy", null ), + + // XXX: depends on content language + array( CONTENT_MODEL_JAVASCRIPT, '/javascript/i' ), + ); + } + + /** + * @dataProvider dataGetLocalizedName + * @covers ContentHandler::getLocalizedName + */ + public function testGetLocalizedName( $id, $expected ) { + $name = ContentHandler::getLocalizedName( $id ); + + if ( $expected ) { + $this->assertNotNull( $name, "no name found for content model $id" ); + $this->assertTrue( preg_match( $expected, $name ) > 0, + "content model name for #$id did not match pattern $expected" + ); + } else { + $this->assertEquals( $id, $name, "localization of unknown model $id should have " + . "fallen back to use the model id directly." + ); + } + } + + public static function dataGetPageLanguage() { + global $wgLanguageCode; + + return array( + array( "Main", $wgLanguageCode ), + array( "Dummy:Foo", $wgLanguageCode ), + array( "MediaWiki:common.js", 'en' ), + array( "User:Foo/common.js", 'en' ), + array( "MediaWiki:common.css", 'en' ), + array( "User:Foo/common.css", 'en' ), + array( "User:Foo", $wgLanguageCode ), + + array( CONTENT_MODEL_JAVASCRIPT, 'javascript' ), + ); + } + + /** + * @dataProvider dataGetPageLanguage + * @covers ContentHandler::getPageLanguage + */ + public function testGetPageLanguage( $title, $expected ) { + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + + $expected = wfGetLangObj( $expected ); + + $handler = ContentHandler::getForTitle( $title ); + $lang = $handler->getPageLanguage( $title ); + + $this->assertEquals( $expected->getCode(), $lang->getCode() ); + } + + public static function dataGetContentText_Null() { + return array( + array( 'fail' ), + array( 'serialize' ), + array( 'ignore' ), + ); + } + + /** + * @dataProvider dataGetContentText_Null + * @covers ContentHandler::getContentText + */ + public function testGetContentText_Null( $contentHandlerTextFallback ) { + $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback ); + + $content = null; + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( '', $text ); + } + + public static function dataGetContentText_TextContent() { + return array( + array( 'fail' ), + array( 'serialize' ), + array( 'ignore' ), + ); + } + + /** + * @dataProvider dataGetContentText_TextContent + * @covers ContentHandler::getContentText + */ + public function testGetContentText_TextContent( $contentHandlerTextFallback ) { + $this->setMwGlobals( 'wgContentHandlerTextFallback', $contentHandlerTextFallback ); + + $content = new WikitextContent( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->getNativeData(), $text ); + } + + /** + * ContentHandler::getContentText should have thrown an exception for non-text Content object + * @expectedException MWException + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_fail() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'fail' ); + + $content = new DummyContentForTesting( "hello world" ); + + ContentHandler::getContentText( $content ); + } + + /** + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_serialize() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'serialize' ); + + $content = new DummyContentForTesting( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertEquals( $content->serialize(), $text ); + } + + /** + * @covers ContentHandler::getContentText + */ + public function testGetContentText_NonTextContent_ignore() { + $this->setMwGlobals( 'wgContentHandlerTextFallback', 'ignore' ); + + $content = new DummyContentForTesting( "hello world" ); + + $text = ContentHandler::getContentText( $content ); + $this->assertNull( $text ); + } + + /* + public static function makeContent( $text, Title $title, $modelId = null, $format = null ) {} + */ + + public static function dataMakeContent() { + return array( + array( 'hallo', 'Help:Test', null, null, CONTENT_MODEL_WIKITEXT, 'hallo', false ), + array( 'hallo', 'MediaWiki:Test.js', null, null, CONTENT_MODEL_JAVASCRIPT, 'hallo', false ), + array( serialize( 'hallo' ), 'Dummy:Test', null, null, "testing", 'hallo', false ), + + array( + 'hallo', + 'Help:Test', + null, + CONTENT_FORMAT_WIKITEXT, + CONTENT_MODEL_WIKITEXT, + 'hallo', + false + ), + array( + 'hallo', + 'MediaWiki:Test.js', + null, + CONTENT_FORMAT_JAVASCRIPT, + CONTENT_MODEL_JAVASCRIPT, + 'hallo', + false + ), + array( serialize( 'hallo' ), 'Dummy:Test', null, "testing", "testing", 'hallo', false ), + + array( 'hallo', 'Help:Test', CONTENT_MODEL_CSS, null, CONTENT_MODEL_CSS, 'hallo', false ), + array( + 'hallo', + 'MediaWiki:Test.js', + CONTENT_MODEL_CSS, + null, + CONTENT_MODEL_CSS, + 'hallo', + false + ), + array( + serialize( 'hallo' ), + 'Dummy:Test', + CONTENT_MODEL_CSS, + null, + CONTENT_MODEL_CSS, + serialize( 'hallo' ), + false + ), + + array( 'hallo', 'Help:Test', CONTENT_MODEL_WIKITEXT, "testing", null, null, true ), + array( 'hallo', 'MediaWiki:Test.js', CONTENT_MODEL_CSS, "testing", null, null, true ), + array( 'hallo', 'Dummy:Test', CONTENT_MODEL_JAVASCRIPT, "testing", null, null, true ), + ); + } + + /** + * @dataProvider dataMakeContent + * @covers ContentHandler::makeContent + */ + public function testMakeContent( $data, $title, $modelId, $format, + $expectedModelId, $expectedNativeData, $shouldFail + ) { + $title = Title::newFromText( $title ); + + try { + $content = ContentHandler::makeContent( $data, $title, $modelId, $format ); + + if ( $shouldFail ) { + $this->fail( "ContentHandler::makeContent should have failed!" ); + } + + $this->assertEquals( $expectedModelId, $content->getModel(), 'bad model id' ); + $this->assertEquals( $expectedNativeData, $content->getNativeData(), 'bads native data' ); + } catch ( MWException $ex ) { + if ( !$shouldFail ) { + $this->fail( "ContentHandler::makeContent failed unexpectedly: " . $ex->getMessage() ); + } else { + // dummy, so we don't get the "test did not perform any assertions" message. + $this->assertTrue( true ); + } + } + } + + /* + * Test if we become a "Created blank page" summary from getAutoSummary if no Content added to + * page. + */ + public function testGetAutosummary() { + $content = new DummyContentHandlerForTesting( CONTENT_MODEL_WIKITEXT ); + $title = Title::newFromText( 'Help:Test' ); + // Create a new content object with no content + $newContent = ContentHandler::makeContent( '', $title, null, null, CONTENT_MODEL_WIKITEXT ); + // first check, if we become a blank page created summary with the right bitmask + $autoSummary = $content->getAutosummary( null, $newContent, 97 ); + $this->assertEquals( $autoSummary, 'Created blank page' ); + // now check, what we become with another bitmask + $autoSummary = $content->getAutosummary( null, $newContent, 92 ); + $this->assertEquals( $autoSummary, '' ); + } + + /* + public function testSupportsSections() { + $this->markTestIncomplete( "not yet implemented" ); + } + */ + + /** + * @covers ContentHandler::runLegacyHooks + */ + public function testRunLegacyHooks() { + Hooks::register( 'testRunLegacyHooks', __CLASS__ . '::dummyHookHandler' ); + + $content = new WikitextContent( 'test text' ); + $ok = ContentHandler::runLegacyHooks( + 'testRunLegacyHooks', + array( 'foo', &$content, 'bar' ), + false + ); + + $this->assertTrue( $ok, "runLegacyHooks should have returned true" ); + $this->assertEquals( "TEST TEXT", $content->getNativeData() ); + } + + public static function dummyHookHandler( $foo, &$text, $bar ) { + if ( $text === null || $text === false ) { + return false; + } + + $text = strtoupper( $text ); + + return true; + } +} + +class DummyContentHandlerForTesting extends ContentHandler { + + public function __construct( $dataModel ) { + parent::__construct( $dataModel, array( "testing" ) ); + } + + /** + * @see ContentHandler::serializeContent + * + * @param Content $content + * @param string $format + * + * @return string + */ + public function serializeContent( Content $content, $format = null ) { + return $content->serialize(); + } + + /** + * @see ContentHandler::unserializeContent + * + * @param string $blob + * @param string $format Unused. + * + * @return Content + */ + public function unserializeContent( $blob, $format = null ) { + $d = unserialize( $blob ); + + return new DummyContentForTesting( $d ); + } + + /** + * Creates an empty Content object of the type supported by this ContentHandler. + * + */ + public function makeEmptyContent() { + return new DummyContentForTesting( '' ); + } +} + +class DummyContentForTesting extends AbstractContent { + + public function __construct( $data ) { + parent::__construct( "testing" ); + + $this->data = $data; + } + + public function serialize( $format = null ) { + return serialize( $this->data ); + } + + /** + * @return string A string representing the content in a way useful for + * building a full text search index. If no useful representation exists, + * this method returns an empty string. + */ + public function getTextForSearchIndex() { + return ''; + } + + /** + * @return string|bool The wikitext to include when another page includes this content, + * or false if the content is not includable in a wikitext page. + */ + public function getWikitextForTransclusion() { + return false; + } + + /** + * Returns a textual representation of the content suitable for use in edit + * summaries and log messages. + * + * @param int $maxlength Maximum length of the summary text. + * @return string The summary text. + */ + public function getTextForSummary( $maxlength = 250 ) { + return ''; + } + + /** + * Returns native represenation of the data. Interpretation depends on the data model used, + * as given by getDataModel(). + * + * @return mixed The native representation of the content. Could be a string, a nested array + * structure, an object, a binary blob... anything, really. + */ + public function getNativeData() { + return $this->data; + } + + /** + * returns the content's nominal size in bogo-bytes. + * + * @return int + */ + public function getSize() { + return strlen( $this->data ); + } + + /** + * Return a copy of this Content object. The following must be true for the object returned + * if $copy = $original->copy() + * + * * get_class($original) === get_class($copy) + * * $original->getModel() === $copy->getModel() + * * $original->equals( $copy ) + * + * If and only if the Content object is imutable, the copy() method can and should + * return $this. That is, $copy === $original may be true, but only for imutable content + * objects. + * + * @return Content A copy of this object + */ + public function copy() { + return $this; + } + + /** + * Returns true if this content is countable as a "real" wiki page, provided + * that it's also in a countable location (e.g. a current revision in the main namespace). + * + * @param bool $hasLinks If it is known whether this content contains links, + * provide this information here, to avoid redundant parsing to find out. + * @return bool + */ + public function isCountable( $hasLinks = null ) { + return false; + } + + /** + * @param Title $title + * @param int $revId Unused. + * @param null|ParserOptions $options + * @param bool $generateHtml Whether to generate Html (default: true). If false, the result + * of calling getText() on the ParserOutput object returned by this method is undefined. + * + * @return ParserOutput + */ + public function getParserOutput( Title $title, $revId = null, + ParserOptions $options = null, $generateHtml = true + ) { + return new ParserOutput( $this->getNativeData() ); + } + + /** + * @see AbstractContent::fillParserOutput() + * + * @param Title $title Context title for parsing + * @param int|null $revId Revision ID (for {{REVISIONID}}) + * @param ParserOptions $options Parser options + * @param bool $generateHtml Whether or not to generate HTML + * @param ParserOutput &$output The output object to fill (reference). + */ + protected function fillParserOutput( Title $title, $revId, + ParserOptions $options, $generateHtml, ParserOutput &$output ) { + $output = new ParserOutput( $this->getNativeData() ); + } +} diff --git a/tests/phpunit/includes/content/CssContentTest.php b/tests/phpunit/includes/content/CssContentTest.php new file mode 100644 index 00000000..40484d3a --- /dev/null +++ b/tests/phpunit/includes/content/CssContentTest.php @@ -0,0 +1,87 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- needed, because we do need the database to test link updates + */ +class CssContentTest extends JavaScriptContentTest { + + protected function setUp() { + parent::setUp(); + + // Anon user + $user = new User(); + $user->setName( '127.0.0.1' ); + + $this->setMwGlobals( array( + 'wgUser' => $user, + 'wgTextModelsToParse' => array( + CONTENT_MODEL_CSS, + ) + ) ); + } + + public function newContent( $text ) { + return new CssContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + 'MediaWiki:Test.css', + null, + "hello <world>\n", + "<pre class=\"mw-code mw-css\" dir=\"ltr\">\nhello <world>\n\n</pre>" + ), + array( + 'MediaWiki:Test.css', + null, + "/* hello [[world]] */\n", + "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n/* hello [[world]] */\n\n</pre>", + array( + 'Links' => array( + array( 'World' => 0 ) + ) + ) + ), + + // TODO: more...? + ); + } + + /** + * @covers CssContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getModel() ); + } + + /** + * @covers CssContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( CONTENT_MODEL_CSS, $content->getContentHandler()->getModelID() ); + } + + public static function dataEquals() { + return array( + array( new CssContent( 'hallo' ), null, false ), + array( new CssContent( 'hallo' ), new CssContent( 'hallo' ), true ), + array( new CssContent( 'hallo' ), new WikitextContent( 'hallo' ), false ), + array( new CssContent( 'hallo' ), new CssContent( 'HALLO' ), false ), + ); + } + + /** + * @dataProvider dataEquals + * @covers CssContent::equals + */ + public function testEquals( Content $a, Content $b = null, $equal = false ) { + $this->assertEquals( $equal, $a->equals( $b ) ); + } +} diff --git a/tests/phpunit/includes/content/JavaScriptContentTest.php b/tests/phpunit/includes/content/JavaScriptContentTest.php new file mode 100644 index 00000000..7193ec9f --- /dev/null +++ b/tests/phpunit/includes/content/JavaScriptContentTest.php @@ -0,0 +1,293 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- needed, because we do need the database to test link updates + */ +class JavaScriptContentTest extends TextContentTest { + + public function newContent( $text ) { + return new JavaScriptContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + 'MediaWiki:Test.js', + null, + "hello <world>\n", + "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello <world>\n\n</pre>" + ), + array( + 'MediaWiki:Test.js', + null, + "hello(); // [[world]]\n", + "<pre class=\"mw-code mw-js\" dir=\"ltr\">\nhello(); // [[world]]\n\n</pre>", + array( + 'Links' => array( + array( 'World' => 0 ) + ) + ) + ), + + // TODO: more...? + ); + } + + // XXX: Unused function + public static function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + '0', + null + ), + array( WikitextContentTest::$sections, + '2', + null + ), + array( WikitextContentTest::$sections, + '8', + null + ), + ); + } + + // XXX: Unused function + public static function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + '0', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + '', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + '2', + "== TEST ==\nmore fun", + null, + null + ), + array( WikitextContentTest::$sections, + '8', + 'No more', + null, + null + ), + array( WikitextContentTest::$sections, + 'new', + 'No more', + 'New', + null + ), + ); + } + + /** + * @covers JavaScriptContent::addSectionHeader + */ + public function testAddSectionHeader() { + $content = $this->newContent( 'hello world' ); + $c = $content->addSectionHeader( 'test' ); + + $this->assertTrue( $content->equals( $c ) ); + } + + // XXX: currently, preSaveTransform is applied to scripts. this may change or become optional. + public static function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + ), + array( " Foo \n ", + " Foo", + ), + ); + } + + public static function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + 'hello this is ~~~', + ), + array( 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>', + 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>', + ), + ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + null, + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @todo Test needs database! + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo Test needs database! + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + false + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + false + ), + array( 'Foo', + true, + 'link', + false + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + true + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + '[[hel...', + ), + ); + } + + /** + * @covers JavaScriptContent::matchMagicWord + */ + public function testMatchMagicWord() { + $mw = MagicWord::get( "staticredirect" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" ); + $this->assertFalse( + $content->matchMagicWord( $mw ), + "should not have matched magic word, since it's not wikitext" + ); + } + + /** + * @covers JavaScriptContent::updateRedirect + */ + public function testUpdateRedirect() { + $target = Title::newFromText( "testUpdateRedirect_target" ); + + $content = $this->newContent( "#REDIRECT [[Someplace]]" ); + $newContent = $content->updateRedirect( $target ); + + $this->assertTrue( + $content->equals( $newContent ), + "content should be unchanged since it's not wikitext" + ); + } + + /** + * @covers JavaScriptContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getModel() ); + } + + /** + * @covers JavaScriptContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_JAVASCRIPT, $content->getContentHandler()->getModelID() ); + } + + public static function dataEquals() { + return array( + array( new JavaScriptContent( "hallo" ), null, false ), + array( new JavaScriptContent( "hallo" ), new JavaScriptContent( "hallo" ), true ), + array( new JavaScriptContent( "hallo" ), new CssContent( "hallo" ), false ), + array( new JavaScriptContent( "hallo" ), new JavaScriptContent( "HALLO" ), false ), + ); + } +} diff --git a/tests/phpunit/includes/content/JsonContentTest.php b/tests/phpunit/includes/content/JsonContentTest.php new file mode 100644 index 00000000..77b542f4 --- /dev/null +++ b/tests/phpunit/includes/content/JsonContentTest.php @@ -0,0 +1,114 @@ +<?php + +/** + * @author Adam Shorland + * @covers JsonContent + */ +class JsonContentTest extends MediaWikiLangTestCase { + + /** + * @dataProvider provideValidConstruction + */ + public function testValidConstruct( $text, $modelId, $isValid, $expected ) { + $obj = new JsonContent( $text, $modelId ); + $this->assertEquals( $isValid, $obj->isValid() ); + $this->assertEquals( $expected, $obj->getJsonData() ); + } + + public static function provideValidConstruction() { + return array( + array( 'foo', CONTENT_MODEL_JSON, false, null ), + array( FormatJson::encode( array() ), CONTENT_MODEL_JSON, true, array() ), + array( FormatJson::encode( array( 'foo' ) ), CONTENT_MODEL_JSON, true, array( 'foo' ) ), + ); + } + + /** + * @dataProvider provideDataToEncode + */ + public function testBeautifyUsesFormatJson( $data ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $this->assertEquals( FormatJson::encode( $data, true ), $obj->beautifyJSON() ); + } + + public static function provideDataToEncode() { + return array( + array( array() ), + array( array( 'foo' ) ), + array( array( 'foo', 'bar' ) ), + array( array( 'baz' => 'foo', 'bar' ) ), + array( array( 'baz' => 1000, 'bar' ) ), + ); + } + + /** + * @dataProvider provideDataToEncode + */ + public function testPreSaveTransform( $data ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $newObj = $obj->preSaveTransform( $this->getMockTitle(), $this->getMockUser(), $this->getMockParserOptions() ); + $this->assertTrue( $newObj->equals( new JsonContent( FormatJson::encode( $data, true ) ) ) ); + } + + private function getMockTitle() { + return $this->getMockBuilder( 'Title' ) + ->disableOriginalConstructor() + ->getMock(); + } + + private function getMockUser() { + return $this->getMockBuilder( 'User' ) + ->disableOriginalConstructor() + ->getMock(); + } + private function getMockParserOptions() { + return $this->getMockBuilder( 'ParserOptions' ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @dataProvider provideDataAndParserText + */ + public function testFillParserOutput( $data, $expected ) { + $obj = new JsonContent( FormatJson::encode( $data ) ); + $parserOutput = $obj->getParserOutput( $this->getMockTitle(), null, null, true ); + $this->assertInstanceOf( 'ParserOutput', $parserOutput ); + $this->assertEquals( $expected, $parserOutput->getText() ); + } + + public static function provideDataAndParserText() { + return array( + array( + array(), + '<table class="mw-json"><tbody></tbody></table>' + ), + array( + array( 'foo' ), + '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr></tbody></table>' + ), + array( + array( 'foo', 'bar' ), + '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"foo"</td></tr>' . + "\n" . + '<tr><th>1</th><td class="value">"bar"</td></tr></tbody></table>' + ), + array( + array( 'baz' => 'foo', 'bar' ), + '<table class="mw-json"><tbody><tr><th>baz</th><td class="value">"foo"</td></tr>' . + "\n" . + '<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>' + ), + array( + array( 'baz' => 1000, 'bar' ), + '<table class="mw-json"><tbody><tr><th>baz</th><td class="value">1000</td></tr>' . + "\n" . + '<tr><th>0</th><td class="value">"bar"</td></tr></tbody></table>' + ), + array( + array( '<script>alert("evil!")</script>'), + '<table class="mw-json"><tbody><tr><th>0</th><td class="value">"<script>alert("evil!")</script>"</td></tr></tbody></table>', + ), + ); + } +} diff --git a/tests/phpunit/includes/content/TextContentTest.php b/tests/phpunit/includes/content/TextContentTest.php new file mode 100644 index 00000000..2f811094 --- /dev/null +++ b/tests/phpunit/includes/content/TextContentTest.php @@ -0,0 +1,490 @@ +<?php + +/** + * @group ContentHandler + * @group Database + * ^--- needed, because we do need the database to test link updates + */ +class TextContentTest extends MediaWikiLangTestCase { + protected $context; + protected $savedContentGetParserOutput; + + protected function setUp() { + global $wgHooks; + + parent::setUp(); + + // Anon user + $user = new User(); + $user->setName( '127.0.0.1' ); + + $this->context = new RequestContext( new FauxRequest() ); + $this->context->setTitle( Title::newFromText( 'Test' ) ); + $this->context->setUser( $user ); + + $this->setMwGlobals( array( + 'wgUser' => $user, + 'wgTextModelsToParse' => array( + CONTENT_MODEL_WIKITEXT, + CONTENT_MODEL_CSS, + CONTENT_MODEL_JAVASCRIPT, + ), + 'wgUseTidy' => false, + 'wgAlwaysUseTidy' => false, + 'wgCapitalLinks' => true, + ) ); + + // bypass hooks that force custom rendering + if ( isset( $wgHooks['ContentGetParserOutput'] ) ) { + $this->savedContentGetParserOutput = $wgHooks['ContentGetParserOutput']; + unset( $wgHooks['ContentGetParserOutput'] ); + } + } + + public function teardown() { + global $wgHooks; + + // restore hooks that force custom rendering + if ( $this->savedContentGetParserOutput !== null ) { + $wgHooks['ContentGetParserOutput'] = $this->savedContentGetParserOutput; + } + + parent::teardown(); + } + + public function newContent( $text ) { + return new TextContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + 'TextContentTest_testGetParserOutput', + CONTENT_MODEL_TEXT, + "hello ''world'' & [[stuff]]\n", "hello ''world'' & [[stuff]]", + array( + 'Links' => array() + ) + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetParserOutput + * @covers TextContent::getParserOutput + */ + public function testGetParserOutput( $title, $model, $text, $expectedHtml, + $expectedFields = null + ) { + $title = Title::newFromText( $title ); + $content = ContentHandler::makeContent( $text, $title, $model ); + + $po = $content->getParserOutput( $title ); + + $html = $po->getText(); + $html = preg_replace( '#<!--.*?-->#sm', '', $html ); // strip comments + + $this->assertEquals( $expectedHtml, trim( $html ) ); + + if ( $expectedFields ) { + foreach ( $expectedFields as $field => $exp ) { + $f = 'get' . ucfirst( $field ); + $v = call_user_func( array( $po, $f ) ); + + if ( is_array( $exp ) ) { + $this->assertArrayEquals( $exp, $v ); + } else { + $this->assertEquals( $exp, $v ); + } + } + } + + // TODO: assert more properties + } + + public static function dataPreSaveTransform() { + return array( + array( + #0: no signature resolution + 'hello this is ~~~', + 'hello this is ~~~', + ), + array( + #1: rtrim + " Foo \n ", + ' Foo', + ), + ); + } + + /** + * @dataProvider dataPreSaveTransform + * @covers TextContent::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + global $wgContLang; + + $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preSaveTransform( + $this->context->getTitle(), + $this->context->getUser(), + $options + ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public static function dataPreloadTransform() { + return array( + array( + 'hello this is ~~~', + 'hello this is ~~~', + ), + ); + } + + /** + * @dataProvider dataPreloadTransform + * @covers TextContent::preloadTransform + */ + public function testPreloadTransform( $text, $expected ) { + global $wgContLang; + $options = ParserOptions::newFromUserAndLang( $this->context->getUser(), $wgContLang ); + + $content = $this->newContent( $text ); + $content = $content->preloadTransform( $this->context->getTitle(), $options ); + + $this->assertEquals( $expected, $content->getNativeData() ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + null, + ), + ); + } + + /** + * @dataProvider dataGetRedirectTarget + * @covers TextContent::getRedirectTarget + */ + public function testGetRedirectTarget( $text, $expected ) { + $content = $this->newContent( $text ); + $t = $content->getRedirectTarget(); + + if ( is_null( $expected ) ) { + $this->assertNull( $t, "text should not have generated a redirect target: $text" ); + } else { + $this->assertEquals( $expected, $t->getPrefixedText() ); + } + } + + /** + * @dataProvider dataGetRedirectTarget + * @covers TextContent::isRedirect + */ + public function testIsRedirect( $text, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( !is_null( $expected ), $content->isRedirect() ); + } + + /** + * @todo Test needs database! Should be done by a test class in the Database group. + */ + /* + public function getRedirectChain() { + $text = $this->getNativeData(); + return Title::newFromRedirectArray( $text ); + } + */ + + /** + * @todo Test needs database! Should be done by a test class in the Database group. + */ + /* + public function getUltimateRedirectTarget() { + $text = $this->getNativeData(); + return Title::newFromRedirectRecurse( $text ); + } + */ + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + false + ), + ); + } + + /** + * @dataProvider dataIsCountable + * @group Database + * @covers TextContent::isCountable + */ + public function testIsCountable( $text, $hasLinks, $mode, $expected ) { + $this->setMwGlobals( 'wgArticleCountMethod', $mode ); + + $content = $this->newContent( $text ); + + $v = $content->isCountable( $hasLinks, $this->context->getTitle() ); + + $this->assertEquals( + $expected, + $v, + 'isCountable() returned unexpected value ' . var_export( $v, true ) + . ' instead of ' . var_export( $expected, true ) + . " in mode `$mode` for text \"$text\"" + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + '[[hel...', + ), + ); + } + + /** + * @dataProvider dataGetTextForSummary + * @covers TextContent::getTextForSummary + */ + public function testGetTextForSummary( $text, $maxlength, $expected ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $expected, $content->getTextForSummary( $maxlength ) ); + } + + /** + * @covers TextContent::getTextForSearchIndex + */ + public function testGetTextForSearchIndex() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getTextForSearchIndex() ); + } + + /** + * @covers TextContent::copy + */ + public function testCopy() { + $content = $this->newContent( 'hello world.' ); + $copy = $content->copy(); + + $this->assertTrue( $content->equals( $copy ), 'copy must be equal to original' ); + $this->assertEquals( 'hello world.', $copy->getNativeData() ); + } + + /** + * @covers TextContent::getSize + */ + public function testGetSize() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 12, $content->getSize() ); + } + + /** + * @covers TextContent::getNativeData + */ + public function testGetNativeData() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getNativeData() ); + } + + /** + * @covers TextContent::getWikitextForTransclusion + */ + public function testGetWikitextForTransclusion() { + $content = $this->newContent( 'hello world.' ); + + $this->assertEquals( 'hello world.', $content->getWikitextForTransclusion() ); + } + + /** + * @covers TextContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_TEXT, $content->getModel() ); + } + + /** + * @covers TextContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_TEXT, $content->getContentHandler()->getModelID() ); + } + + public static function dataIsEmpty() { + return array( + array( '', true ), + array( ' ', false ), + array( '0', false ), + array( 'hallo welt.', false ), + ); + } + + /** + * @dataProvider dataIsEmpty + * @covers TextContent::isEmpty + */ + public function testIsEmpty( $text, $empty ) { + $content = $this->newContent( $text ); + + $this->assertEquals( $empty, $content->isEmpty() ); + } + + public static function dataEquals() { + return array( + array( new TextContent( "hallo" ), null, false ), + array( new TextContent( "hallo" ), new TextContent( "hallo" ), true ), + array( new TextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ), + array( new TextContent( "hallo" ), new WikitextContent( "hallo" ), false ), + array( new TextContent( "hallo" ), new TextContent( "HALLO" ), false ), + ); + } + + /** + * @dataProvider dataEquals + * @covers TextContent::equals + */ + public function testEquals( Content $a, Content $b = null, $equal = false ) { + $this->assertEquals( $equal, $a->equals( $b ) ); + } + + public static function dataGetDeletionUpdates() { + return array( + array( "TextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_TEXT, "hello ''world''\n", + array() + ), + array( "TextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_TEXT, "hello [[world test 21344]]\n", + array() + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetDeletionUpdates + * @covers TextContent::getDeletionUpdates + */ + public function testDeletionUpdates( $title, $model, $text, $expectedStuff ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $page->doEditContent( $content, '' ); + + $updates = $content->getDeletionUpdates( $page ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[$class] = $update; + } + + if ( !$expectedStuff ) { + $this->assertTrue( true ); // make phpunit happy + return; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[$class]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + + $page->doDeleteArticle( '' ); + } + + public static function provideConvert() { + return array( + array( // #0 + 'Hallo Welt', + CONTENT_MODEL_WIKITEXT, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_WIKITEXT, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_CSS, + 'lossless', + 'Hallo Welt' + ), + array( // #1 + 'Hallo Welt', + CONTENT_MODEL_JAVASCRIPT, + 'lossless', + 'Hallo Welt' + ), + ); + } + + /** + * @dataProvider provideConvert + * @covers TextContent::convert + */ + public function testConvert( $text, $model, $lossy, $expectedNative ) { + $content = $this->newContent( $text ); + + $converted = $content->convert( $model, $lossy ); + + if ( $expectedNative === false ) { + $this->assertFalse( $converted, "conversion to $model was expected to fail!" ); + } else { + $this->assertInstanceOf( 'Content', $converted ); + $this->assertEquals( $expectedNative, $converted->getNativeData() ); + } + } +} diff --git a/tests/phpunit/includes/content/WikitextContentHandlerTest.php b/tests/phpunit/includes/content/WikitextContentHandlerTest.php new file mode 100644 index 00000000..38fb5733 --- /dev/null +++ b/tests/phpunit/includes/content/WikitextContentHandlerTest.php @@ -0,0 +1,241 @@ +<?php + +/** + * @group ContentHandler + */ +class WikitextContentHandlerTest extends MediaWikiLangTestCase { + /** + * @var ContentHandler + */ + private $handler; + + protected function setUp() { + parent::setUp(); + + $this->handler = ContentHandler::getForModelID( CONTENT_MODEL_WIKITEXT ); + } + + /** + * @covers WikitextContentHandler::serializeContent + */ + public function testSerializeContent() { + $content = new WikitextContent( 'hello world' ); + + $this->assertEquals( 'hello world', $this->handler->serializeContent( $content ) ); + $this->assertEquals( + 'hello world', + $this->handler->serializeContent( $content, CONTENT_FORMAT_WIKITEXT ) + ); + + try { + $this->handler->serializeContent( $content, 'dummy/foo' ); + $this->fail( "serializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + /** + * @covers WikitextContentHandler::unserializeContent + */ + public function testUnserializeContent() { + $content = $this->handler->unserializeContent( 'hello world' ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + $content = $this->handler->unserializeContent( 'hello world', CONTENT_FORMAT_WIKITEXT ); + $this->assertEquals( 'hello world', $content->getNativeData() ); + + try { + $this->handler->unserializeContent( 'hello world', 'dummy/foo' ); + $this->fail( "unserializeContent() should have failed on unknown format" ); + } catch ( MWException $e ) { + // ok, as expected + } + } + + /** + * @covers WikitextContentHandler::makeEmptyContent + */ + public function testMakeEmptyContent() { + $content = $this->handler->makeEmptyContent(); + + $this->assertTrue( $content->isEmpty() ); + $this->assertEquals( '', $content->getNativeData() ); + } + + public static function dataIsSupportedFormat() { + return array( + array( null, true ), + array( CONTENT_FORMAT_WIKITEXT, true ), + array( 99887766, false ), + ); + } + + /** + * @dataProvider provideMakeRedirectContent + * @param Title|string $title Title object or string for Title::newFromText() + * @param string $expected Serialized form of the content object built + * @covers WikitextContentHandler::makeRedirectContent + */ + public function testMakeRedirectContent( $title, $expected ) { + global $wgContLang; + $wgContLang->resetNamespaces(); + + MagicWord::clearCache(); + + if ( is_string( $title ) ) { + $title = Title::newFromText( $title ); + } + $content = $this->handler->makeRedirectContent( $title ); + $this->assertEquals( $expected, $content->serialize() ); + } + + public static function provideMakeRedirectContent() { + return array( + array( 'Hello', '#REDIRECT [[Hello]]' ), + array( 'Template:Hello', '#REDIRECT [[Template:Hello]]' ), + array( 'Hello#section', '#REDIRECT [[Hello#section]]' ), + array( 'user:john_doe#section', '#REDIRECT [[User:John doe#section]]' ), + array( 'MEDIAWIKI:FOOBAR', '#REDIRECT [[MediaWiki:FOOBAR]]' ), + array( 'Category:Foo', '#REDIRECT [[:Category:Foo]]' ), + array( Title::makeTitle( NS_MAIN, 'en:Foo' ), '#REDIRECT [[en:Foo]]' ), + array( Title::makeTitle( NS_MAIN, 'Foo', '', 'en' ), '#REDIRECT [[:en:Foo]]' ), + array( + Title::makeTitle( NS_MAIN, 'Bar', 'fragment', 'google' ), + '#REDIRECT [[google:Bar#fragment]]' + ), + ); + } + + /** + * @dataProvider dataIsSupportedFormat + * @covers WikitextContentHandler::isSupportedFormat + */ + public function testIsSupportedFormat( $format, $supported ) { + $this->assertEquals( $supported, $this->handler->isSupportedFormat( $format ) ); + } + + public static function dataMerge3() { + return array( + array( + "first paragraph + + second paragraph\n", + + "FIRST paragraph + + second paragraph\n", + + "first paragraph + + SECOND paragraph\n", + + "FIRST paragraph + + SECOND paragraph\n", + ), + + array( "first paragraph + second paragraph\n", + + "Bla bla\n", + + "Blubberdibla\n", + + false, + ), + ); + } + + /** + * @dataProvider dataMerge3 + * @covers WikitextContentHandler::merge3 + */ + public function testMerge3( $old, $mine, $yours, $expected ) { + $this->checkHasDiff3(); + + // test merge + $oldContent = new WikitextContent( $old ); + $myContent = new WikitextContent( $mine ); + $yourContent = new WikitextContent( $yours ); + + $merged = $this->handler->merge3( $oldContent, $myContent, $yourContent ); + + $this->assertEquals( $expected, $merged ? $merged->getNativeData() : $merged ); + } + + public static function dataGetAutosummary() { + return array( + array( + 'Hello there, world!', + '#REDIRECT [[Foo]]', + 0, + '/^Redirected page .*Foo/' + ), + + array( + null, + 'Hello world!', + EDIT_NEW, + '/^Created page .*Hello/' + ), + + array( + 'Hello there, world!', + '', + 0, + '/^Blanked/' + ), + + array( + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy + eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam + voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet + clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + 'Hello world!', + 0, + '/^Replaced .*Hello/' + ), + + array( + 'foo', + 'bar', + 0, + '/^$/' + ), + ); + } + + /** + * @dataProvider dataGetAutosummary + * @covers WikitextContentHandler::getAutosummary + */ + public function testGetAutosummary( $old, $new, $flags, $expected ) { + $oldContent = is_null( $old ) ? null : new WikitextContent( $old ); + $newContent = is_null( $new ) ? null : new WikitextContent( $new ); + + $summary = $this->handler->getAutosummary( $oldContent, $newContent, $flags ); + + $this->assertTrue( + (bool)preg_match( $expected, $summary ), + "Autosummary didn't match expected pattern $expected: $summary" + ); + } + + /** + * @todo Text case requires database, should be done by a test class in the Database group + */ + /* + public function testGetAutoDeleteReason( Title $title, &$hasHistory ) {} + */ + + /** + * @todo Text case requires database, should be done by a test class in the Database group + */ + /* + public function testGetUndoContent( Revision $current, Revision $undo, + Revision $undoafter = null + ) { + } + */ +} diff --git a/tests/phpunit/includes/content/WikitextContentTest.php b/tests/phpunit/includes/content/WikitextContentTest.php new file mode 100644 index 00000000..7becd6f4 --- /dev/null +++ b/tests/phpunit/includes/content/WikitextContentTest.php @@ -0,0 +1,433 @@ +<?php + +/** + * @group ContentHandler + * + * @group Database + * ^--- needed, because we do need the database to test link updates + */ +class WikitextContentTest extends TextContentTest { + public static $sections = "Intro + +== stuff == +hello world + +== test == +just a test + +== foo == +more stuff +"; + + public function newContent( $text ) { + return new WikitextContent( $text ); + } + + public static function dataGetParserOutput() { + return array( + array( + "WikitextContentTest_testGetParserOutput", + CONTENT_MODEL_WIKITEXT, + "hello ''world''\n", + "<p>hello <i>world</i>\n</p>" + ), + // TODO: more...? + ); + } + + public static function dataGetSecondaryDataUpdates() { + return array( + array( "WikitextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_WIKITEXT, "hello ''world''\n", + array( + 'LinksUpdate' => array( + 'mRecursive' => true, + 'mLinks' => array() + ) + ) + ), + array( "WikitextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n", + array( + 'LinksUpdate' => array( + 'mRecursive' => true, + 'mLinks' => array( + array( 'World_test_21344' => 0 ) + ) + ) + ) + ), + // TODO: more...? + ); + } + + /** + * @dataProvider dataGetSecondaryDataUpdates + * @group Database + * @covers WikitextContent::getSecondaryDataUpdates + */ + public function testGetSecondaryDataUpdates( $title, $model, $text, $expectedStuff ) { + $ns = $this->getDefaultWikitextNS(); + $title = Title::newFromText( $title, $ns ); + + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $page->doEditContent( $content, '' ); + + $updates = $content->getSecondaryDataUpdates( $title ); + + // make updates accessible by class name + foreach ( $updates as $update ) { + $class = get_class( $update ); + $updates[$class] = $update; + } + + foreach ( $expectedStuff as $class => $fieldValues ) { + $this->assertArrayHasKey( $class, $updates, "missing an update of type $class" ); + + $update = $updates[$class]; + + foreach ( $fieldValues as $field => $value ) { + $v = $update->$field; #if the field doesn't exist, just crash and burn + $this->assertEquals( $value, $v, "unexpected value for field $field in instance of $class" ); + } + } + + $page->doDeleteArticle( '' ); + } + + public static function dataGetSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "Intro" + ), + array( WikitextContentTest::$sections, + "2", + "== test == +just a test" + ), + array( WikitextContentTest::$sections, + "8", + false + ), + ); + } + + /** + * @dataProvider dataGetSection + * @covers WikitextContent::getSection + */ + public function testGetSection( $text, $sectionId, $expectedText ) { + $content = $this->newContent( $text ); + + $sectionContent = $content->getSection( $sectionId ); + if ( is_object( $sectionContent ) ) { + $sectionText = $sectionContent->getNativeData(); + } else { + $sectionText = $sectionContent; + } + + $this->assertEquals( $expectedText, $sectionText ); + } + + public static function dataReplaceSection() { + return array( + array( WikitextContentTest::$sections, + "0", + "No more", + null, + trim( preg_replace( '/^Intro/sm', 'No more', WikitextContentTest::$sections ) ) + ), + array( WikitextContentTest::$sections, + "", + "No more", + null, + "No more" + ), + array( WikitextContentTest::$sections, + "2", + "== TEST ==\nmore fun", + null, + trim( preg_replace( + '/^== test ==.*== foo ==/sm', "== TEST ==\nmore fun\n\n== foo ==", + WikitextContentTest::$sections + ) ) + ), + array( WikitextContentTest::$sections, + "8", + "No more", + null, + WikitextContentTest::$sections + ), + array( WikitextContentTest::$sections, + "new", + "No more", + "New", + trim( WikitextContentTest::$sections ) . "\n\n\n== New ==\n\nNo more" + ), + ); + } + + /** + * @dataProvider dataReplaceSection + * @covers WikitextContent::replaceSection + */ + public function testReplaceSection( $text, $section, $with, $sectionTitle, $expected ) { + $content = $this->newContent( $text ); + $c = $content->replaceSection( $section, $this->newContent( $with ), $sectionTitle ); + + $this->assertEquals( $expected, is_null( $c ) ? null : $c->getNativeData() ); + } + + /** + * @covers WikitextContent::addSectionHeader + */ + public function testAddSectionHeader() { + $content = $this->newContent( 'hello world' ); + $content = $content->addSectionHeader( 'test' ); + + $this->assertEquals( "== test ==\n\nhello world", $content->getNativeData() ); + } + + public static function dataPreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + ), + array( // rtrim + " Foo \n ", + " Foo", + ), + ); + } + + public static function dataPreloadTransform() { + return array( + array( 'hello this is ~~~', + "hello this is ~~~", + ), + array( 'hello \'\'this\'\' is <noinclude>foo</noinclude><includeonly>bar</includeonly>', + 'hello \'\'this\'\' is bar', + ), + ); + } + + public static function dataGetRedirectTarget() { + return array( + array( '#REDIRECT [[Test]]', + 'Test', + ), + array( '#REDIRECT Test', + null, + ), + array( '* #REDIRECT [[Test]]', + null, + ), + ); + } + + public static function dataGetTextForSummary() { + return array( + array( "hello\nworld.", + 16, + 'hello world.', + ), + array( 'hello world.', + 8, + 'hello...', + ), + array( '[[hello world]].', + 8, + 'hel...', + ), + ); + } + + public static function dataIsCountable() { + return array( + array( '', + null, + 'any', + true + ), + array( 'Foo', + null, + 'any', + true + ), + array( 'Foo', + null, + 'comma', + false + ), + array( 'Foo, bar', + null, + 'comma', + true + ), + array( 'Foo', + null, + 'link', + false + ), + array( 'Foo [[bar]]', + null, + 'link', + true + ), + array( 'Foo', + true, + 'link', + true + ), + array( 'Foo [[bar]]', + false, + 'link', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'any', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'comma', + false + ), + array( '#REDIRECT [[bar]]', + true, + 'link', + false + ), + ); + } + + /** + * @covers WikitextContent::matchMagicWord + */ + public function testMatchMagicWord() { + $mw = MagicWord::get( "staticredirect" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]\n__STATICREDIRECT__" ); + $this->assertTrue( $content->matchMagicWord( $mw ), "should have matched magic word" ); + + $content = $this->newContent( "#REDIRECT [[FOO]]" ); + $this->assertFalse( $content->matchMagicWord( $mw ), "should not have matched magic word" ); + } + + /** + * @covers WikitextContent::updateRedirect + */ + public function testUpdateRedirect() { + $target = Title::newFromText( "testUpdateRedirect_target" ); + + // test with non-redirect page + $content = $this->newContent( "hello world." ); + $newContent = $content->updateRedirect( $target ); + + $this->assertTrue( $content->equals( $newContent ), "content should be unchanged" ); + + // test with actual redirect + $content = $this->newContent( "#REDIRECT [[Someplace]]" ); + $newContent = $content->updateRedirect( $target ); + + $this->assertFalse( $content->equals( $newContent ), "content should have changed" ); + $this->assertTrue( $newContent->isRedirect(), "new content should be a redirect" ); + + $this->assertEquals( $target->getFullText(), $newContent->getRedirectTarget()->getFullText() ); + } + + /** + * @covers WikitextContent::getModel + */ + public function testGetModel() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getModel() ); + } + + /** + * @covers WikitextContent::getContentHandler + */ + public function testGetContentHandler() { + $content = $this->newContent( "hello world." ); + + $this->assertEquals( CONTENT_MODEL_WIKITEXT, $content->getContentHandler()->getModelID() ); + } + + public function testRedirectParserOption() { + $title = Title::newFromText( 'testRedirectParserOption' ); + + // Set up hook and its reporting variables + $wikitext = null; + $redirectTarget = null; + $this->mergeMwGlobalArrayValue( 'wgHooks', array( + 'InternalParseBeforeLinks' => array( + function ( &$parser, &$text, &$stripState ) use ( &$wikitext, &$redirectTarget ) { + $wikitext = $text; + $redirectTarget = $parser->getOptions()->getRedirectTarget(); + } + ) + ) ); + + // Test with non-redirect page + $wikitext = false; + $redirectTarget = false; + $content = $this->newContent( 'hello world.' ); + $options = $content->getContentHandler()->makeParserOptions( 'canonical' ); + $options->setRedirectTarget( $title ); + $content->getParserOutput( $title, null, $options ); + $this->assertEquals( 'hello world.', $wikitext, + 'Wikitext passed to hook was not as expected' + ); + $this->assertEquals( null, $redirectTarget, 'Redirect seen in hook was not null' ); + $this->assertEquals( $title, $options->getRedirectTarget(), + 'ParserOptions\' redirectTarget was changed' + ); + + // Test with a redirect page + $wikitext = false; + $redirectTarget = false; + $content = $this->newContent( "#REDIRECT [[TestRedirectParserOption/redir]]\nhello redirect." ); + $options = $content->getContentHandler()->makeParserOptions( 'canonical' ); + $content->getParserOutput( $title, null, $options ); + $this->assertEquals( 'hello redirect.', $wikitext, 'Wikitext passed to hook was not as expected' ); + $this->assertNotEquals( null, $redirectTarget, 'Redirect seen in hook was null' ); + $this->assertEquals( 'TestRedirectParserOption/redir', $redirectTarget->getFullText(), + 'Redirect seen in hook was not the expected title' + ); + $this->assertEquals( null, $options->getRedirectTarget(), + 'ParserOptions\' redirectTarget was changed' + ); + } + + public static function dataEquals() { + return array( + array( new WikitextContent( "hallo" ), null, false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "hallo" ), true ), + array( new WikitextContent( "hallo" ), new JavaScriptContent( "hallo" ), false ), + array( new WikitextContent( "hallo" ), new TextContent( "hallo" ), false ), + array( new WikitextContent( "hallo" ), new WikitextContent( "HALLO" ), false ), + ); + } + + public static function dataGetDeletionUpdates() { + return array( + array( "WikitextContentTest_testGetSecondaryDataUpdates_1", + CONTENT_MODEL_WIKITEXT, "hello ''world''\n", + array( 'LinksDeletionUpdate' => array() ) + ), + array( "WikitextContentTest_testGetSecondaryDataUpdates_2", + CONTENT_MODEL_WIKITEXT, "hello [[world test 21344]]\n", + array( 'LinksDeletionUpdate' => array() ) + ), + // @todo more...? + ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php new file mode 100644 index 00000000..55e48d13 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseMysqlBaseTest.php @@ -0,0 +1,247 @@ +<?php +/** + * Holds tests for DatabaseMysqlBase MediaWiki class. + * + * @section LICENSE + * 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 + * @author Antoine Musso + * @author Bryan Davis + * @copyright © 2013 Antoine Musso + * @copyright © 2013 Bryan Davis + * @copyright © 2013 Wikimedia Foundation Inc. + */ + +/** + * Fake class around abstract class so we can call concrete methods. + */ +class FakeDatabaseMysqlBase extends DatabaseMysqlBase { + // From DatabaseBase + function __construct() { + } + + protected function closeConnection() { + } + + protected function doQuery( $sql ) { + } + + // From DatabaseMysql + protected function mysqlConnect( $realServer ) { + } + + protected function mysqlSetCharset( $charset ) { + } + + protected function mysqlFreeResult( $res ) { + } + + protected function mysqlFetchObject( $res ) { + } + + protected function mysqlFetchArray( $res ) { + } + + protected function mysqlNumRows( $res ) { + } + + protected function mysqlNumFields( $res ) { + } + + protected function mysqlFieldName( $res, $n ) { + } + + protected function mysqlFieldType( $res, $n ) { + } + + protected function mysqlDataSeek( $res, $row ) { + } + + protected function mysqlError( $conn = null ) { + } + + protected function mysqlFetchField( $res, $n ) { + } + + protected function mysqlPing() { + } + + // From interface DatabaseType + function insertId() { + } + + function lastErrno() { + } + + function affectedRows() { + } + + function getServerVersion() { + } +} + +class DatabaseMysqlBaseTest extends MediaWikiTestCase { + /** + * @dataProvider provideDiapers + * @covers DatabaseMysqlBase::addIdentifierQuotes + */ + public function testAddIdentifierQuotes( $expected, $in ) { + $db = new FakeDatabaseMysqlBase(); + $quoted = $db->addIdentifierQuotes( $in ); + $this->assertEquals( $expected, $quoted ); + } + + /** + * Feeds testAddIdentifierQuotes + * + * Named per bug 20281 convention. + */ + function provideDiapers() { + return array( + // Format: expected, input + array( '``', '' ), + + // Yeah I really hate loosely typed PHP idiocies nowadays + array( '``', null ), + + // Dear codereviewer, guess what addIdentifierQuotes() + // will return with thoses: + array( '``', false ), + array( '`1`', true ), + + // We never know what could happen + array( '`0`', 0 ), + array( '`1`', 1 ), + + // Whatchout! Should probably use something more meaningful + array( "`'`", "'" ), # single quote + array( '`"`', '"' ), # double quote + array( '````', '`' ), # backtick + array( '`’`', '’' ), # apostrophe (look at your encyclopedia) + + // sneaky NUL bytes are lurking everywhere + array( '``', "\0" ), + array( '`xyzzy`', "\0x\0y\0z\0z\0y\0" ), + + // unicode chars + array( + self::createUnicodeString( '`\u0001a\uFFFFb`' ), + self::createUnicodeString( '\u0001a\uFFFFb' ) + ), + array( + self::createUnicodeString( '`\u0001\uFFFF`' ), + self::createUnicodeString( '\u0001\u0000\uFFFF\u0000' ) + ), + array( '`☃`', '☃' ), + array( '`メインページ`', 'メインページ' ), + array( '`Басты_бет`', 'Басты_бет' ), + + // Real world: + array( '`Alix`', 'Alix' ), # while( ! $recovered ) { sleep(); } + array( '`Backtick: ```', 'Backtick: `' ), + array( '`This is a test`', 'This is a test' ), + ); + } + + private static function createUnicodeString( $str ) { + return json_decode( '"' . $str . '"' ); + } + + function getMockForViews() { + $db = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->setMethods( array( 'fetchRow', 'query' ) ) + ->getMock(); + + $db->expects( $this->any() ) + ->method( 'query' ) + ->with( $this->anything() ) + ->will( + $this->returnValue( null ) + ); + + $db->expects( $this->any() ) + ->method( 'fetchRow' ) + ->with( $this->anything() ) + ->will( $this->onConsecutiveCalls( + array( 'Tables_in_' => 'view1' ), + array( 'Tables_in_' => 'view2' ), + array( 'Tables_in_' => 'myview' ), + false # no more rows + )); + return $db; + } + /** + * @covers DatabaseMysqlBase::listViews + */ + function testListviews() { + $db = $this->getMockForViews(); + + // The first call populate an internal cache of views + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews() ); + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews() ); + + // Prefix filtering + $this->assertEquals( array( 'view1', 'view2' ), + $db->listViews( 'view' ) ); + $this->assertEquals( array( 'myview' ), + $db->listViews( 'my' ) ); + $this->assertEquals( array(), + $db->listViews( 'UNUSED_PREFIX' ) ); + $this->assertEquals( array( 'view1', 'view2', 'myview' ), + $db->listViews( '' ) ); + } + + /** + * @covers DatabaseMysqlBase::isView + * @dataProvider provideViewExistanceChecks + */ + function testIsView( $isView, $viewName ) { + $db = $this->getMockForViews(); + + switch ( $isView ) { + case true: + $this->assertTrue( $db->isView( $viewName ), + "$viewName should be considered a view" ); + break; + + case false: + $this->assertFalse( $db->isView( $viewName ), + "$viewName has not been defined as a view" ); + break; + } + + } + + function provideViewExistanceChecks() { + return array( + // format: whether it is a view, view name + array( true, 'view1' ), + array( true, 'view2' ), + array( true, 'myview' ), + + array( false, 'user' ), + + array( false, 'view10' ), + array( false, 'my' ), + array( false, 'OH_MY_GOD' ), # they killed kenny! + ); + } + +} diff --git a/tests/phpunit/includes/db/DatabaseSQLTest.php b/tests/phpunit/includes/db/DatabaseSQLTest.php new file mode 100644 index 00000000..5c2d4b70 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseSQLTest.php @@ -0,0 +1,725 @@ +<?php + +/** + * Test the abstract database layer + * This is a non DBMS depending test. + */ +class DatabaseSQLTest extends MediaWikiTestCase { + + /** + * @var DatabaseTestHelper + */ + private $database; + + protected function setUp() { + parent::setUp(); + $this->database = new DatabaseTestHelper( __CLASS__ ); + } + + protected function assertLastSql( $sqlText ) { + $this->assertEquals( + $this->database->getLastSqls(), + $sqlText + ); + } + + /** + * @dataProvider provideSelect + * @covers DatabaseBase::select + */ + public function testSelect( $sql, $sqlText ) { + $this->database->select( + $sql['tables'], + $sql['fields'], + isset( $sql['conds'] ) ? $sql['conds'] : array(), + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array(), + isset( $sql['join_conds'] ) ? $sql['join_conds'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelect() { + return array( + array( + array( + 'tables' => 'table', + 'fields' => array( 'field', 'alias' => 'field2' ), + 'conds' => array( 'alias' => 'text' ), + ), + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ), + array( + array( + 'tables' => 'table', + 'fields' => array( 'field', 'alias' => 'field2' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'ORDER BY' => 'field' ), + ), + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'ORDER BY' => 'field' ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( 'LIMIT' => 1, 'GROUP BY' => 'field', 'HAVING' => 'COUNT(*) > 1' ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field HAVING COUNT(*) > 1 " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table', 't2' => 'table2' ), + 'fields' => array( 'tid', 'field', 'alias' => 'field2', 't2.id' ), + 'conds' => array( 'alias' => 'text' ), + 'options' => array( + 'LIMIT' => 1, + 'GROUP BY' => array( 'field', 'field2' ), + 'HAVING' => array( 'COUNT(*) > 1', 'field' => 1 ) + ), + 'join_conds' => array( 't2' => array( + 'LEFT JOIN', 'tid = t2.id' + ) ), + ), + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field,field2 HAVING (COUNT(*) > 1) AND field = '1' " . + "LIMIT 1" + ), + array( + array( + 'tables' => array( 'table' ), + 'fields' => array( 'alias' => 'field' ), + 'conds' => array( 'alias' => array( 1, 2, 3, 4 ) ), + ), + "SELECT field AS alias " . + "FROM table " . + "WHERE alias IN ('1','2','3','4')" + ), + ); + } + + /** + * @dataProvider provideUpdate + * @covers DatabaseBase::update + */ + public function testUpdate( $sql, $sqlText ) { + $this->database->update( + $sql['table'], + $sql['values'], + $sql['conds'], + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpdate() { + return array( + array( + array( + 'table' => 'table', + 'values' => array( 'field' => 'text', 'field2' => 'text2' ), + 'conds' => array( 'alias' => 'text' ), + ), + "UPDATE table " . + "SET field = 'text'" . + ",field2 = 'text2' " . + "WHERE alias = 'text'" + ), + array( + array( + 'table' => 'table', + 'values' => array( 'field = other', 'field2' => 'text2' ), + 'conds' => array( 'id' => '1' ), + ), + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2' " . + "WHERE id = '1'" + ), + array( + array( + 'table' => 'table', + 'values' => array( 'field = other', 'field2' => 'text2' ), + 'conds' => '*', + ), + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2'" + ), + ); + } + + /** + * @dataProvider provideDelete + * @covers DatabaseBase::delete + */ + public function testDelete( $sql, $sqlText ) { + $this->database->delete( + $sql['table'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDelete() { + return array( + array( + array( + 'table' => 'table', + 'conds' => array( 'alias' => 'text' ), + ), + "DELETE FROM table " . + "WHERE alias = 'text'" + ), + array( + array( + 'table' => 'table', + 'conds' => '*', + ), + "DELETE FROM table" + ), + ); + } + + /** + * @dataProvider provideUpsert + * @covers DatabaseBase::upsert + */ + public function testUpsert( $sql, $sqlText ) { + $this->database->upsert( + $sql['table'], + $sql['rows'], + $sql['uniqueIndexes'], + $sql['set'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpsert() { + return array( + array( + array( + 'table' => 'upsert_table', + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + 'uniqueIndexes' => array( 'field' ), + 'set' => array( 'field' => 'set' ), + ), + "BEGIN; " . + "UPDATE upsert_table " . + "SET field = 'set' " . + "WHERE ((field = 'text')); " . + "INSERT IGNORE INTO upsert_table " . + "(field,field2) " . + "VALUES ('text','text2'); " . + "COMMIT" + ), + ); + } + + /** + * @dataProvider provideDeleteJoin + * @covers DatabaseBase::deleteJoin + */ + public function testDeleteJoin( $sql, $sqlText ) { + $this->database->deleteJoin( + $sql['delTable'], + $sql['joinTable'], + $sql['delVar'], + $sql['joinVar'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDeleteJoin() { + return array( + array( + array( + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => array( 'alias' => 'text' ), + ), + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join WHERE alias = 'text'" . + ")" + ), + array( + array( + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => '*', + ), + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join " . + ")" + ), + ); + } + + /** + * @dataProvider provideInsert + * @covers DatabaseBase::insert + */ + public function testInsert( $sql, $sqlText ) { + $this->database->insert( + $sql['table'], + $sql['rows'], + __METHOD__, + isset( $sql['options'] ) ? $sql['options'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsert() { + return array( + array( + array( + 'table' => 'table', + 'rows' => array( 'field' => 'text', 'field2' => 2 ), + ), + "INSERT INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ), + array( + array( + 'table' => 'table', + 'rows' => array( 'field' => 'text', 'field2' => 2 ), + 'options' => 'IGNORE', + ), + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ), + array( + array( + 'table' => 'table', + 'rows' => array( + array( 'field' => 'text', 'field2' => 2 ), + array( 'field' => 'multi', 'field2' => 3 ), + ), + 'options' => 'IGNORE', + ), + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES " . + "('text','2')," . + "('multi','3')" + ), + ); + } + + /** + * @dataProvider provideInsertSelect + * @covers DatabaseBase::insertSelect + */ + public function testInsertSelect( $sql, $sqlText ) { + $this->database->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : array(), + isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : array() + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsertSelect() { + return array( + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => '*', + ), + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table" + ), + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => array( 'field' => 2 ), + ), + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2'" + ), + array( + array( + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => array( 'field_insert' => 'field_select', 'field' => 'field2' ), + 'conds' => array( 'field' => 2 ), + 'insertOptions' => 'IGNORE', + 'selectOptions' => array( 'ORDER BY' => 'field' ), + ), + "INSERT IGNORE INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2' " . + "ORDER BY field" + ), + ); + } + + /** + * @dataProvider provideReplace + * @covers DatabaseBase::replace + */ + public function testReplace( $sql, $sqlText ) { + $this->database->replace( + $sql['table'], + $sql['uniqueIndexes'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideReplace() { + return array( + array( + array( + 'table' => 'replace_table', + 'uniqueIndexes' => array( 'field' ), + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + ), + "DELETE FROM replace_table " . + "WHERE ( field='text' ); " . + "INSERT INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( array( 'md_module', 'md_skin' ) ), + 'rows' => array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' AND md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( array( 'md_module', 'md_skin' ) ), + 'rows' => array( + array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), array( + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ), + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' AND md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE ( md_module='module2' AND md_skin='skin2' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array( 'md_module', 'md_skin' ), + 'rows' => array( + array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), array( + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ), + ), + ), + "DELETE FROM module_deps " . + "WHERE ( md_module='module' ) OR ( md_skin='skin' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE ( md_module='module2' ) OR ( md_skin='skin2' ); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2')" + ), + array( + array( + 'table' => 'module_deps', + 'uniqueIndexes' => array(), + 'rows' => array( + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ), + ), + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps')" + ), + ); + } + + /** + * @dataProvider provideNativeReplace + * @covers DatabaseBase::nativeReplace + */ + public function testNativeReplace( $sql, $sqlText ) { + $this->database->nativeReplace( + $sql['table'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideNativeReplace() { + return array( + array( + array( + 'table' => 'replace_table', + 'rows' => array( 'field' => 'text', 'field2' => 'text2' ), + ), + "REPLACE INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ), + ); + } + + /** + * @dataProvider provideConditional + * @covers DatabaseBase::conditional + */ + public function testConditional( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->conditional( + $sql['conds'], + $sql['true'], + $sql['false'] + ) ), $sqlText ); + } + + public static function provideConditional() { + return array( + array( + array( + 'conds' => array( 'field' => 'text' ), + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field = 'text' THEN 1 ELSE NULL END)" + ), + array( + array( + 'conds' => array( 'field' => 'text', 'field2' => 'anothertext' ), + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field = 'text' AND field2 = 'anothertext' THEN 1 ELSE NULL END)" + ), + array( + array( + 'conds' => 'field=1', + 'true' => 1, + 'false' => 'NULL', + ), + "(CASE WHEN field=1 THEN 1 ELSE NULL END)" + ), + ); + } + + /** + * @dataProvider provideBuildConcat + * @covers DatabaseBase::buildConcat + */ + public function testBuildConcat( $stringList, $sqlText ) { + $this->assertEquals( trim( $this->database->buildConcat( + $stringList + ) ), $sqlText ); + } + + public static function provideBuildConcat() { + return array( + array( + array( 'field', 'field2' ), + "CONCAT(field,field2)" + ), + array( + array( "'test'", 'field2' ), + "CONCAT('test',field2)" + ), + ); + } + + /** + * @dataProvider provideBuildLike + * @covers DatabaseBase::buildLike + */ + public function testBuildLike( $array, $sqlText ) { + $this->assertEquals( trim( $this->database->buildLike( + $array + ) ), $sqlText ); + } + + public static function provideBuildLike() { + return array( + array( + 'text', + "LIKE 'text'" + ), + array( + array( 'text', new LikeMatch( '%' ) ), + "LIKE 'text%'" + ), + array( + array( 'text', new LikeMatch( '%' ), 'text2' ), + "LIKE 'text%text2'" + ), + array( + array( 'text', new LikeMatch( '_' ) ), + "LIKE 'text_'" + ), + ); + } + + /** + * @dataProvider provideUnionQueries + * @covers DatabaseBase::unionQueries + */ + public function testUnionQueries( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->unionQueries( + $sql['sqls'], + $sql['all'] + ) ), $sqlText ); + } + + public static function provideUnionQueries() { + return array( + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL' ), + 'all' => true, + ), + "(RAW SQL) UNION ALL (RAW2SQL)" + ), + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL' ), + 'all' => false, + ), + "(RAW SQL) UNION (RAW2SQL)" + ), + array( + array( + 'sqls' => array( 'RAW SQL', 'RAW2SQL', 'RAW3SQL' ), + 'all' => false, + ), + "(RAW SQL) UNION (RAW2SQL) UNION (RAW3SQL)" + ), + ); + } + + /** + * @covers DatabaseBase::commit + */ + public function testTransactionCommit() { + $this->database->begin( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + } + + /** + * @covers DatabaseBase::rollback + */ + public function testTransactionRollback() { + $this->database->begin( __METHOD__ ); + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers DatabaseBase::dropTable + */ + public function testDropTable() { + $this->database->setExistingTables( array( 'table' ) ); + $this->database->dropTable( 'table', __METHOD__ ); + $this->assertLastSql( 'DROP TABLE table' ); + } + + /** + * @covers DatabaseBase::dropTable + */ + public function testDropNonExistingTable() { + $this->assertFalse( + $this->database->dropTable( 'non_existing', __METHOD__ ) + ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php new file mode 100644 index 00000000..98b4ca04 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseSqliteTest.php @@ -0,0 +1,455 @@ +<?php + +class MockDatabaseSqlite extends DatabaseSqliteStandalone { + private $lastQuery; + + function __construct() { + parent::__construct( ':memory:' ); + } + + function query( $sql, $fname = '', $tempIgnore = false ) { + $this->lastQuery = $sql; + + return true; + } + + /** + * Override parent visibility to public + */ + public function replaceVars( $s ) { + return parent::replaceVars( $s ); + } +} + +/** + * @group sqlite + * @group Database + * @group medium + */ +class DatabaseSqliteTest extends MediaWikiTestCase { + /** @var MockDatabaseSqlite */ + protected $db; + + protected function setUp() { + parent::setUp(); + + if ( !Sqlite::isPresent() ) { + $this->markTestSkipped( 'No SQLite support detected' ); + } + $this->db = new MockDatabaseSqlite(); + if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) { + $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" ); + } + } + + private function replaceVars( $sql ) { + // normalize spacing to hide implementation details + return preg_replace( '/\s+/', ' ', $this->db->replaceVars( $sql ) ); + } + + private function assertResultIs( $expected, $res ) { + $this->assertNotNull( $res ); + $i = 0; + foreach ( $res as $row ) { + foreach ( $expected[$i] as $key => $value ) { + $this->assertTrue( isset( $row->$key ) ); + $this->assertEquals( $value, $row->$key ); + } + $i++; + } + $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' ); + } + + public static function provideAddQuotes() { + return array( + array( // #0: empty + '', "''" + ), + array( // #1: simple + 'foo bar', "'foo bar'" + ), + array( // #2: including quote + 'foo\'bar', "'foo''bar'" + ), + // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419) + array( + "x\0y", + "x'780079'", + ), + array( // #4: blob object (must be represented as hex) + new Blob( "hello" ), + "x'68656c6c6f'", + ), + ); + } + + /** + * @dataProvider provideAddQuotes() + * @covers DatabaseSqlite::addQuotes + */ + public function testAddQuotes( $value, $expected ) { + // check quoting + $db = new DatabaseSqliteStandalone( ':memory:' ); + $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' ); + + // ok, quoting works as expected, now try a round trip. + $re = $db->query( 'select ' . $db->addQuotes( $value ) ); + + $this->assertTrue( $re !== false, 'query failed' ); + + if ( $row = $re->fetchRow() ) { + if ( $value instanceof Blob ) { + $value = $value->fetch(); + } + + $this->assertEquals( $value, $row[0], 'string mangled by the database' ); + } else { + $this->fail( 'query returned no result' ); + } + } + + /** + * @covers DatabaseSqlite::replaceVars + */ + public function testReplaceVars() { + $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" ); + + $this->assertEquals( + "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, " + . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );", + $this->replaceVars( + "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, " + . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', " + . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;" + ) + ); + + $this->assertEquals( + "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );", + $this->replaceVars( + "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );" + ) + ); + + $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );", + $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" ) + ); + + $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );", + $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ), + 'Table name changed' + ); + + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" ) + ); + $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );", + $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" ) + ); + + $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)", + $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" ) + ); + + $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42", + $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" ) + ); + + $this->assertEquals( "DROP INDEX foo", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" ) + ); + + $this->assertEquals( "DROP INDEX foo -- dropping index", + $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" ) + ); + $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')", + $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" ) + ); + } + + /** + * @covers DatabaseSqlite::tableName + */ + public function testTableName() { + // @todo Moar! + $db = new DatabaseSqliteStandalone( ':memory:' ); + $this->assertEquals( 'foo', $db->tableName( 'foo' ) ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $db->tablePrefix( 'foo' ); + $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) ); + $this->assertEquals( 'foobar', $db->tableName( 'bar' ) ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructure() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->query( 'CREATE TABLE foo(foo, barfoo)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'bar' ) ), + 'Normal table duplication' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)', + $db->selectField( 'sqlite_temp_master', 'sql', array( 'name' => 'baz' ) ), + 'Creation of temporary duplicate' + ); + $this->assertEquals( 0, + $db->selectField( 'sqlite_master', 'COUNT(*)', array( 'name' => 'baz' ) ), + 'Create a temporary duplicate only' + ); + } + + /** + * @covers DatabaseSqlite::duplicateTableStructure + */ + public function testDuplicateTableStructureVirtual() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + if ( $db->getFulltextSearchModule() != 'FTS3' ) { + $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' ); + } + $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' ); + + $db->duplicateTableStructure( 'foo', 'bar' ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'bar' ) ), + 'Duplication of virtual tables' + ); + + $db->duplicateTableStructure( 'foo', 'baz', true ); + $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)', + $db->selectField( 'sqlite_master', 'sql', array( 'name' => 'baz' ) ), + "Can't create temporary virtual tables, should fall back to non-temporary duplication" + ); + } + + /** + * @covers DatabaseSqlite::deleteJoin + */ + public function testDeleteJoin() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->query( 'CREATE TABLE a (a_1)', __METHOD__ ); + $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ ); + $db->insert( 'a', array( + array( 'a_1' => 1 ), + array( 'a_1' => 2 ), + array( 'a_1' => 3 ), + ), + __METHOD__ + ); + $db->insert( 'b', array( + array( 'b_1' => 2, 'b_2' => 'a' ), + array( 'b_1' => 3, 'b_2' => 'b' ), + ), + __METHOD__ + ); + $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', array( 'b_2' => 'a' ), __METHOD__ ); + $res = $db->query( "SELECT * FROM a", __METHOD__ ); + $this->assertResultIs( array( + array( 'a_1' => 1 ), + array( 'a_1' => 3 ), + ), + $res + ); + } + + public function testEntireSchema() { + global $IP; + + $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" ); + if ( $result !== true ) { + $this->fail( $result ); + } + $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions + } + + /** + * Runs upgrades of older databases and compares results with current schema + * @todo Currently only checks list of tables + */ + public function testUpgrades() { + global $IP, $wgVersion, $wgProfileToDatabase; + + // Versions tested + $versions = array( + //'1.13', disabled for now, was totally screwed up + // SQLite wasn't included in 1.14 + '1.15', + '1.16', + '1.17', + '1.18', + ); + + // Mismatches for these columns we can safely ignore + $ignoredColumns = array( + 'user_newtalk.user_last_timestamp', // r84185 + ); + + $currentDB = new DatabaseSqliteStandalone( ':memory:' ); + $currentDB->sourceFile( "$IP/maintenance/tables.sql" ); + if ( $wgProfileToDatabase ) { + $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" ); + } + $currentTables = $this->getTables( $currentDB ); + sort( $currentTables ); + + foreach ( $versions as $version ) { + $versions = "upgrading from $version to $wgVersion"; + $db = $this->prepareDB( $version ); + $tables = $this->getTables( $db ); + $this->assertEquals( $currentTables, $tables, "Different tables $versions" ); + foreach ( $tables as $table ) { + $currentCols = $this->getColumns( $currentDB, $table ); + $cols = $this->getColumns( $db, $table ); + $this->assertEquals( + array_keys( $currentCols ), + array_keys( $cols ), + "Mismatching columns for table \"$table\" $versions" + ); + foreach ( $currentCols as $name => $column ) { + $fullName = "$table.$name"; + $this->assertEquals( + (bool)$column->pk, + (bool)$cols[$name]->pk, + "PRIMARY KEY status does not match for column $fullName $versions" + ); + if ( !in_array( $fullName, $ignoredColumns ) ) { + $this->assertEquals( + (bool)$column->notnull, + (bool)$cols[$name]->notnull, + "NOT NULL status does not match for column $fullName $versions" + ); + $this->assertEquals( + $column->dflt_value, + $cols[$name]->dflt_value, + "Default values does not match for column $fullName $versions" + ); + } + } + $currentIndexes = $this->getIndexes( $currentDB, $table ); + $indexes = $this->getIndexes( $db, $table ); + $this->assertEquals( + array_keys( $currentIndexes ), + array_keys( $indexes ), + "mismatching indexes for table \"$table\" $versions" + ); + } + $db->close(); + } + } + + /** + * @covers DatabaseSqlite::insertId + */ + public function testInsertIdType() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( 'ResultWrapper', $databaseCreation, "Database creation" ); + + $insertion = $db->insert( 'a', array( 'a_1' => 10 ), __METHOD__ ); + $this->assertTrue( $insertion, "Insertion worked" ); + + $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" ); + $this->assertTrue( $db->close(), "closing database" ); + } + + private function prepareDB( $version ) { + static $maint = null; + if ( $maint === null ) { + $maint = new FakeMaintenance(); + $maint->loadParamsAndArgs( null, array( 'quiet' => 1 ) ); + } + + global $IP; + $db = new DatabaseSqliteStandalone( ':memory:' ); + $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" ); + $updater = DatabaseUpdater::newForDB( $db, false, $maint ); + $updater->doUpdates( array( 'core' ) ); + + return $db; + } + + private function getTables( $db ) { + $list = array_flip( $db->listTables() ); + $excluded = array( + 'external_user', // removed from core in 1.22 + 'math', // moved out of core in 1.18 + 'trackbacks', // removed from core in 1.19 + 'searchindex', + 'searchindex_content', + 'searchindex_segments', + 'searchindex_segdir', + // FTS4 ready!!1 + 'searchindex_docsize', + 'searchindex_stat', + ); + foreach ( $excluded as $t ) { + unset( $list[$t] ); + } + $list = array_flip( $list ); + sort( $list ); + + return $list; + } + + private function getColumns( $db, $table ) { + $cols = array(); + $res = $db->query( "PRAGMA table_info($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $col ) { + $cols[$col->name] = $col; + } + ksort( $cols ); + + return $cols; + } + + private function getIndexes( $db, $table ) { + $indexes = array(); + $res = $db->query( "PRAGMA index_list($table)" ); + $this->assertNotNull( $res ); + foreach ( $res as $index ) { + $res2 = $db->query( "PRAGMA index_info({$index->name})" ); + $this->assertNotNull( $res2 ); + $index->columns = array(); + foreach ( $res2 as $col ) { + $index->columns[] = $col; + } + $indexes[$index->name] = $index; + } + ksort( $indexes ); + + return $indexes; + } + + public function testCaseInsensitiveLike() { + // TODO: Test this for all databases + $db = new DatabaseSqliteStandalone( ':memory:' ); + $res = $db->query( 'SELECT "a" LIKE "A" AS a' ); + $row = $res->fetchRow(); + $this->assertFalse( (bool)$row['a'] ); + } + + /** + * @covers DatabaseSqlite::numFields + */ + public function testNumFields() { + $db = new DatabaseSqliteStandalone( ':memory:' ); + + $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ ); + $this->assertInstanceOf( 'ResultWrapper', $databaseCreation, "Failed to create table a" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" ); + $insertion = $db->insert( 'a', array( 'a_1' => 10 ), __METHOD__ ); + $this->assertTrue( $insertion, "Insertion failed" ); + $res = $db->select( 'a', '*' ); + $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" ); + + $this->assertTrue( $db->close(), "closing database" ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseTest.php b/tests/phpunit/includes/db/DatabaseTest.php new file mode 100644 index 00000000..7e704396 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseTest.php @@ -0,0 +1,237 @@ +<?php + +/** + * @group Database + * @group DatabaseBase + */ +class DatabaseTest extends MediaWikiTestCase { + /** + * @var DatabaseBase + */ + protected $db; + + private $functionTest = false; + + protected function setUp() { + parent::setUp(); + $this->db = wfGetDB( DB_MASTER ); + } + + protected function tearDown() { + parent::tearDown(); + if ( $this->functionTest ) { + $this->dropFunctions(); + $this->functionTest = false; + } + } + /** + * @covers DatabaseBase::dropTable + */ + public function testAddQuotesNull() { + $check = "NULL"; + if ( $this->db->getType() === 'sqlite' || $this->db->getType() === 'oracle' ) { + $check = "''"; + } + $this->assertEquals( $check, $this->db->addQuotes( null ) ); + } + + public function testAddQuotesInt() { + # returning just "1234" should be ok too, though... + # maybe + $this->assertEquals( + "'1234'", + $this->db->addQuotes( 1234 ) ); + } + + public function testAddQuotesFloat() { + # returning just "1234.5678" would be ok too, though + $this->assertEquals( + "'1234.5678'", + $this->db->addQuotes( 1234.5678 ) ); + } + + public function testAddQuotesString() { + $this->assertEquals( + "'string'", + $this->db->addQuotes( 'string' ) ); + } + + public function testAddQuotesStringQuote() { + $check = "'string''s cause trouble'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "'string\'s cause trouble'"; + } + $this->assertEquals( + $check, + $this->db->addQuotes( "string's cause trouble" ) ); + } + + private function getSharedTableName( $table, $database, $prefix, $format = 'quoted' ) { + global $wgSharedDB, $wgSharedTables, $wgSharedPrefix; + + $oldName = $wgSharedDB; + $oldTables = $wgSharedTables; + $oldPrefix = $wgSharedPrefix; + + $wgSharedDB = $database; + $wgSharedTables = array( $table ); + $wgSharedPrefix = $prefix; + + $ret = $this->db->tableName( $table, $format ); + + $wgSharedDB = $oldName; + $wgSharedTables = $oldTables; + $wgSharedPrefix = $oldPrefix; + + return $ret; + } + + private function prefixAndQuote( $table, $database = null, $prefix = null, $format = 'quoted' ) { + if ( $this->db->getType() === 'sqlite' || $format !== 'quoted' ) { + $quote = ''; + } elseif ( $this->db->getType() === 'mysql' ) { + $quote = '`'; + } elseif ( $this->db->getType() === 'oracle' ) { + $quote = '/*Q*/'; + } else { + $quote = '"'; + } + + if ( $database !== null ) { + if ( $this->db->getType() === 'oracle' ) { + $database = $quote . $database . '.'; + } else { + $database = $quote . $database . $quote . '.'; + } + } + + if ( $prefix === null ) { + $prefix = $this->dbPrefix(); + } + + if ( $this->db->getType() === 'oracle' ) { + return strtoupper( $database . $quote . $prefix . $table ); + } else { + return $database . $quote . $prefix . $table . $quote; + } + } + + public function testTableNameLocal() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename' ), + $this->db->tableName( 'tablename' ) + ); + } + + public function testTableNameRawLocal() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', null, null, 'raw' ), + $this->db->tableName( 'tablename', 'raw' ) + ); + } + + public function testTableNameShared() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', 'sh_' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', 'sh_' ) + ); + + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', null ), + $this->getSharedTableName( 'tablename', 'sharedatabase', null ) + ); + } + + public function testTableNameRawShared() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', 'sh_', 'raw' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', 'sh_', 'raw' ) + ); + + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'sharedatabase', null, 'raw' ), + $this->getSharedTableName( 'tablename', 'sharedatabase', null, 'raw' ) + ); + } + + public function testTableNameForeign() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'databasename', '' ), + $this->db->tableName( 'databasename.tablename' ) + ); + } + + public function testTableNameRawForeign() { + $this->assertEquals( + $this->prefixAndQuote( 'tablename', 'databasename', '', 'raw' ), + $this->db->tableName( 'databasename.tablename', 'raw' ) + ); + } + + public function testFillPreparedEmpty() { + $sql = $this->db->fillPrepared( + 'SELECT * FROM interwiki', array() ); + $this->assertEquals( + "SELECT * FROM interwiki", + $sql ); + } + + public function testFillPreparedQuestion() { + $sql = $this->db->fillPrepared( + 'SELECT * FROM cur WHERE cur_namespace=? AND cur_title=?', + array( 4, "Snicker's_paradox" ) ); + + $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker''s_paradox'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "SELECT * FROM cur WHERE cur_namespace='4' AND cur_title='Snicker\'s_paradox'"; + } + $this->assertEquals( $check, $sql ); + } + + public function testFillPreparedBang() { + $sql = $this->db->fillPrepared( + 'SELECT user_id FROM ! WHERE user_name=?', + array( '"user"', "Slash's Dot" ) ); + + $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash''s Dot'"; + if ( $this->db->getType() === 'mysql' ) { + $check = "SELECT user_id FROM \"user\" WHERE user_name='Slash\'s Dot'"; + } + $this->assertEquals( $check, $sql ); + } + + public function testFillPreparedRaw() { + $sql = $this->db->fillPrepared( + "SELECT * FROM cur WHERE cur_title='This_\\&_that,_WTF\\?\\!'", + array( '"user"', "Slash's Dot" ) ); + $this->assertEquals( + "SELECT * FROM cur WHERE cur_title='This_&_that,_WTF?!'", + $sql ); + } + + public function testStoredFunctions() { + if ( !in_array( wfGetDB( DB_MASTER )->getType(), array( 'mysql', 'postgres' ) ) ) { + $this->markTestSkipped( 'MySQL or Postgres required' ); + } + global $IP; + $this->dropFunctions(); + $this->functionTest = true; + $this->assertTrue( + $this->db->sourceFile( "$IP/tests/phpunit/data/db/{$this->db->getType()}/functions.sql" ) + ); + $res = $this->db->query( 'SELECT mw_test_function() AS test', __METHOD__ ); + $this->assertEquals( 42, $res->fetchObject()->test ); + } + + private function dropFunctions() { + $this->db->query( 'DROP FUNCTION IF EXISTS mw_test_function' + . ( $this->db->getType() == 'postgres' ? '()' : '' ) + ); + } + + public function testUnknownTableCorruptsResults() { + $res = $this->db->select( 'page', '*', array( 'page_id' => 1 ) ); + $this->assertFalse( $this->db->tableExists( 'foobarbaz' ) ); + $this->assertInternalType( 'int', $res->numRows() ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseTestHelper.php b/tests/phpunit/includes/db/DatabaseTestHelper.php new file mode 100644 index 00000000..0c0b3902 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseTestHelper.php @@ -0,0 +1,170 @@ +<?php + +/** + * Helper for testing the methods from the DatabaseBase class + * @since 1.22 + */ +class DatabaseTestHelper extends DatabaseBase { + + /** + * __CLASS__ of the test suite, + * used to determine, if the function name is passed every time to query() + */ + protected $testName = array(); + + /** + * Array of lastSqls passed to query(), + * This is an array since some methods in DatabaseBase can do more than one + * query. Cleared when calling getLastSqls(). + */ + protected $lastSqls = array(); + + /** + * Array of tables to be considered as existing by tableExist() + * Use setExistingTables() to alter. + */ + protected $tablesExists; + + public function __construct( $testName ) { + $this->testName = $testName; + } + + /** + * Returns SQL queries grouped by '; ' + * Clear the list of queries that have been done so far. + */ + public function getLastSqls() { + $lastSqls = implode( '; ', $this->lastSqls ); + $this->lastSqls = array(); + + return $lastSqls; + } + + public function setExistingTables( $tablesExists ) { + $this->tablesExists = (array)$tablesExists; + } + + protected function addSql( $sql ) { + // clean up spaces before and after some words and the whole string + $this->lastSqls[] = trim( preg_replace( + '/\s{2,}(?=FROM|WHERE|GROUP BY|ORDER BY|LIMIT)|(?<=SELECT|INSERT|UPDATE)\s{2,}/', + ' ', $sql + ) ); + } + + protected function checkFunctionName( $fname ) { + if ( substr( $fname, 0, strlen( $this->testName ) ) !== $this->testName ) { + throw new MWException( 'function name does not start with test class. ' . + $fname . ' vs. ' . $this->testName . '. ' . + 'Please provide __METHOD__ to database methods.' ); + } + } + + function strencode( $s ) { + // Choose apos to avoid handling of escaping double quotes in quoted text + return str_replace( "'", "\'", $s ); + } + + public function addIdentifierQuotes( $s ) { + // no escaping to avoid handling of double quotes in quoted text + return $s; + } + + public function query( $sql, $fname = '', $tempIgnore = false ) { + $this->checkFunctionName( $fname ); + $this->addSql( $sql ); + + return parent::query( $sql, $fname, $tempIgnore ); + } + + public function tableExists( $table, $fname = __METHOD__ ) { + $this->checkFunctionName( $fname ); + + return in_array( $table, (array)$this->tablesExists ); + } + + // Redeclare parent method to make it public + public function nativeReplace( $table, $rows, $fname ) { + return parent::nativeReplace( $table, $rows, $fname ); + } + + function getType() { + return 'test'; + } + + function open( $server, $user, $password, $dbName ) { + return false; + } + + function fetchObject( $res ) { + return false; + } + + function fetchRow( $res ) { + return false; + } + + function numRows( $res ) { + return -1; + } + + function numFields( $res ) { + return -1; + } + + function fieldName( $res, $n ) { + return 'test'; + } + + function insertId() { + return -1; + } + + function dataSeek( $res, $row ) { + /* nop */ + } + + function lastErrno() { + return -1; + } + + function lastError() { + return 'test'; + } + + function fieldInfo( $table, $field ) { + return false; + } + + function indexInfo( $table, $index, $fname = 'DatabaseBase::indexInfo' ) { + return false; + } + + function affectedRows() { + return -1; + } + + function getSoftwareLink() { + return 'test'; + } + + function getServerVersion() { + return 'test'; + } + + function getServerInfo() { + return 'test'; + } + + function isOpen() { + return true; + } + + protected function closeConnection() { + return false; + } + + protected function doQuery( $sql ) { + return array(); + } +} diff --git a/tests/phpunit/includes/db/LBFactoryTest.php b/tests/phpunit/includes/db/LBFactoryTest.php new file mode 100644 index 00000000..4c59f474 --- /dev/null +++ b/tests/phpunit/includes/db/LBFactoryTest.php @@ -0,0 +1,61 @@ +<?php +/** + * Holds tests for LBFactory abstract MediaWiki class. + * + * @section LICENSE + * 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 + * + * @group Database + * @file + * @author Antoine Musso + * @copyright © 2013 Antoine Musso + * @copyright © 2013 Wikimedia Foundation Inc. + */ +class LBFactoryTest extends MediaWikiTestCase { + + /** + * @dataProvider getLBFactoryClassProvider + */ + public function testGetLBFactoryClass( $expected, $deprecated ) { + $mockDB = $this->getMockBuilder( 'DatabaseMysql' ) + ->disableOriginalConstructor() + ->getMock(); + + $config = array( + 'class' => $deprecated, + 'connection' => $mockDB, + # Various other parameters required: + 'sectionsByDB' => array(), + 'sectionLoads' => array(), + 'serverTemplate' => array(), + ); + + $this->hideDeprecated( '$wgLBFactoryConf must be updated. See RELEASE-NOTES for details' ); + $result = LBFactory::getLBFactoryClass( $config ); + + $this->assertEquals( $expected, $result ); + } + + public function getLBFactoryClassProvider() { + return array( + # Format: new class, old class + array( 'LBFactorySimple', 'LBFactory_Simple' ), + array( 'LBFactorySingle', 'LBFactory_Single' ), + array( 'LBFactoryMulti', 'LBFactory_Multi' ), + array( 'LBFactoryFake', 'LBFactory_Fake' ), + ); + } +} diff --git a/tests/phpunit/includes/db/ORMRowTest.php b/tests/phpunit/includes/db/ORMRowTest.php new file mode 100644 index 00000000..447bf219 --- /dev/null +++ b/tests/phpunit/includes/db/ORMRowTest.php @@ -0,0 +1,226 @@ +<?php + +/** + * Abstract class to construct tests for ORMRow deriving classes. + * + * 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 + * @since 1.20 + * + * @ingroup Test + * + * @group ORM + * + * The database group has as a side effect that temporal database tables are created. This makes + * it possible to test without poisoning a production database. + * @group Database + * + * Some of the tests takes more time, and needs therefor longer time before they can be aborted + * as non-functional. The reason why tests are aborted is assumed to be set up of temporal databases + * that hold the first tests in a pending state awaiting access to the database. + * @group medium + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +abstract class ORMRowTest extends \MediaWikiTestCase { + + /** + * @since 1.20 + * @return string + */ + abstract protected function getRowClass(); + + /** + * @since 1.20 + * @return IORMTable + */ + abstract protected function getTableInstance(); + + /** + * @since 1.20 + * @return array + */ + abstract public function constructorTestProvider(); + + /** + * @since 1.20 + * @param IORMRow $row + * @param array $data + */ + protected function verifyFields( IORMRow $row, array $data ) { + foreach ( array_keys( $data ) as $fieldName ) { + $this->assertEquals( $data[$fieldName], $row->getField( $fieldName ) ); + } + } + + /** + * @since 1.20 + * @param array $data + * @param bool $loadDefaults + * @return IORMRow + */ + protected function getRowInstance( array $data, $loadDefaults ) { + $class = $this->getRowClass(); + + return new $class( $this->getTableInstance(), $data, $loadDefaults ); + } + + /** + * @since 1.20 + * @return array + */ + protected function getMockValues() { + return array( + 'id' => 1, + 'str' => 'foobar4645645', + 'int' => 42, + 'float' => 4.2, + 'bool' => true, + 'array' => array( 42, 'foobar' ), + 'blob' => new stdClass() + ); + } + + /** + * @since 1.20 + * @return array + */ + protected function getMockFields() { + $mockValues = $this->getMockValues(); + $mockFields = array(); + + foreach ( $this->getTableInstance()->getFields() as $name => $type ) { + if ( $name !== 'id' ) { + $mockFields[$name] = $mockValues[$type]; + } + } + + return $mockFields; + } + + /** + * @since 1.20 + * @return array Array of IORMRow + */ + public function instanceProvider() { + $instances = array(); + + foreach ( $this->constructorTestProvider() as $arguments ) { + $instances[] = array( call_user_func_array( array( $this, 'getRowInstance' ), $arguments ) ); + } + + return $instances; + } + + /** + * @dataProvider constructorTestProvider + */ + public function testConstructor( array $data, $loadDefaults ) { + $this->verifyFields( $this->getRowInstance( $data, $loadDefaults ), $data ); + } + + /** + * @dataProvider constructorTestProvider + */ + public function testSaveAndRemove( array $data, $loadDefaults ) { + $item = $this->getRowInstance( $data, $loadDefaults ); + + $this->assertTrue( $item->save() ); + + $this->assertTrue( $item->hasIdField() ); + $this->assertTrue( is_integer( $item->getId() ) ); + + $id = $item->getId(); + + $this->assertTrue( $item->save() ); + + $this->assertEquals( $id, $item->getId() ); + + $this->verifyFields( $item, $data ); + + $this->assertTrue( $item->remove() ); + + $this->assertFalse( $item->hasIdField() ); + + $this->assertTrue( $item->save() ); + + $this->verifyFields( $item, $data ); + + $this->assertTrue( $item->remove() ); + + $this->assertFalse( $item->hasIdField() ); + + $this->verifyFields( $item, $data ); + } + + /** + * @dataProvider instanceProvider + */ + public function testSetField( IORMRow $item ) { + foreach ( $this->getMockFields() as $name => $value ) { + $item->setField( $name, $value ); + $this->assertEquals( $value, $item->getField( $name ) ); + } + } + + /** + * @since 1.20 + * @param array $expected + * @param IORMRow $item + */ + protected function assertFieldValues( array $expected, IORMRow $item ) { + foreach ( $expected as $name => $type ) { + if ( $name !== 'id' ) { + $this->assertEquals( $expected[$name], $item->getField( $name ) ); + } + } + } + + /** + * @dataProvider instanceProvider + */ + public function testSetFields( IORMRow $item ) { + $originalValues = $item->getFields(); + + $item->setFields( array(), false ); + + foreach ( $item->getTable()->getFields() as $name => $type ) { + $originalHas = array_key_exists( $name, $originalValues ); + $newHas = $item->hasField( $name ); + + $this->assertEquals( $originalHas, $newHas ); + + if ( $originalHas && $newHas ) { + $this->assertEquals( $originalValues[$name], $item->getField( $name ) ); + } + } + + $mockFields = $this->getMockFields(); + + $item->setFields( $mockFields, false ); + + $this->assertFieldValues( $originalValues, $item ); + + $item->setFields( $mockFields, true ); + + $this->assertFieldValues( $mockFields, $item ); + } + + // TODO: test all of the methods! + +} diff --git a/tests/phpunit/includes/db/ORMTableTest.php b/tests/phpunit/includes/db/ORMTableTest.php new file mode 100644 index 00000000..7171ee59 --- /dev/null +++ b/tests/phpunit/includes/db/ORMTableTest.php @@ -0,0 +1,150 @@ +<?php +/** + * Abstract class to construct tests for ORMTable deriving classes. + * + * 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 + * @since 1.21 + * + * @ingroup Test + * + * @group ORM + * @group Database + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + * @author Daniel Kinzler + */ + +/** + * @covers PageORMTableForTesting + */ +class ORMTableTest extends MediaWikiTestCase { + + /** + * @since 1.21 + * @return string + */ + protected function getTableClass() { + return 'PageORMTableForTesting'; + } + + /** + * @since 1.21 + * @return IORMTable + */ + public function getTable() { + $class = $this->getTableClass(); + + return $class::singleton(); + } + + /** + * @since 1.21 + * @return string + */ + public function getRowClass() { + return $this->getTable()->getRowClass(); + } + + /** + * @since 1.21 + */ + public function testSingleton() { + $class = $this->getTableClass(); + + $this->assertInstanceOf( $class, $class::singleton() ); + $this->assertTrue( $class::singleton() === $class::singleton() ); + } + + /** + * @since 1.21 + */ + public function testIgnoreErrorsOverride() { + $table = $this->getTable(); + + $db = $table->getReadDbConnection(); + $db->ignoreErrors( true ); + + try { + $table->rawSelect( "this is invalid" ); + $this->fail( "An invalid query should trigger a DBQueryError even if ignoreErrors is enabled." ); + } catch ( DBQueryError $ex ) { + $this->assertTrue( true, "just making phpunit happy" ); + } + + $db->ignoreErrors( false ); + } +} + +/** + * Dummy ORM table for testing, reading Title objects from the page table. + * + * @since 1.21 + */ + +class PageORMTableForTesting extends ORMTable { + + /** + * @see ORMTable::getName + * + * @return string + */ + public function getName() { + return 'page'; + } + + /** + * @see ORMTable::getRowClass + * + * @return string + */ + public function getRowClass() { + return 'Title'; + } + + /** + * @see ORMTable::newRow + * + * @return IORMRow + */ + public function newRow( array $data, $loadDefaults = false ) { + return Title::makeTitle( $data['namespace'], $data['title'] ); + } + + /** + * @see ORMTable::getFields + * + * @return array + */ + public function getFields() { + return array( + 'id' => 'int', + 'namespace' => 'int', + 'title' => 'str', + ); + } + + /** + * @see ORMTable::getFieldPrefix + * + * @return string + */ + protected function getFieldPrefix() { + return 'page_'; + } +} diff --git a/tests/phpunit/includes/db/TestORMRowTest.php b/tests/phpunit/includes/db/TestORMRowTest.php new file mode 100644 index 00000000..c9459c90 --- /dev/null +++ b/tests/phpunit/includes/db/TestORMRowTest.php @@ -0,0 +1,218 @@ +<?php + +/** + * Tests for the TestORMRow class. + * TestORMRow is a dummy class to be able to test the abstract ORMRow class. + * + * 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 + * @since 1.20 + * + * @ingroup Test + * + * @group ORM + * + * The database group has as a side effect that temporal database tables are created. This makes + * it possible to test without poisoning a production database. + * @group Database + * + * Some of the tests takes more time, and needs therefor longer time before they can be aborted + * as non-functional. The reason why tests are aborted is assumed to be set up of temporal databases + * that hold the first tests in a pending state awaiting access to the database. + * @group medium + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +require_once __DIR__ . "/ORMRowTest.php"; + +/** + * @covers TestORMRow + */ +class TestORMRowTest extends ORMRowTest { + + /** + * @since 1.20 + * @return string + */ + protected function getRowClass() { + return 'TestORMRow'; + } + + /** + * @since 1.20 + * @return IORMTable + */ + protected function getTableInstance() { + return TestORMTable::singleton(); + } + + protected function setUp() { + parent::setUp(); + + $dbw = wfGetDB( DB_MASTER ); + + $isSqlite = $GLOBALS['wgDBtype'] === 'sqlite'; + $isPostgres = $GLOBALS['wgDBtype'] === 'postgres'; + + $idField = $isSqlite ? 'INTEGER' : 'INT unsigned'; + $primaryKey = $isSqlite ? 'PRIMARY KEY AUTOINCREMENT' : 'auto_increment PRIMARY KEY'; + + if ( $isPostgres ) { + $dbw->query( + 'CREATE TABLE IF NOT EXISTS ' . $dbw->tableName( 'orm_test' ) . "( + test_id serial PRIMARY KEY, + test_name TEXT NOT NULL DEFAULT '', + test_age INTEGER NOT NULL DEFAULT 0, + test_height REAL NOT NULL DEFAULT 0, + test_awesome INTEGER NOT NULL DEFAULT 0, + test_stuff BYTEA, + test_moarstuff BYTEA, + test_time TIMESTAMPTZ + );", + __METHOD__ + ); + } else { + $dbw->query( + 'CREATE TABLE IF NOT EXISTS ' . $dbw->tableName( 'orm_test' ) . '( + test_id ' . $idField . ' NOT NULL ' . $primaryKey . ', + test_name VARCHAR(255) NOT NULL, + test_age TINYINT unsigned NOT NULL, + test_height FLOAT NOT NULL, + test_awesome TINYINT unsigned NOT NULL, + test_stuff BLOB NOT NULL, + test_moarstuff BLOB NOT NULL, + test_time varbinary(14) NOT NULL + );', + __METHOD__ + ); + } + } + + protected function tearDown() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->dropTable( 'orm_test', __METHOD__ ); + + parent::tearDown(); + } + + public function constructorTestProvider() { + $dbw = wfGetDB( DB_MASTER ); + return array( + array( + array( + 'name' => 'Foobar', + 'time' => $dbw->timestamp( '20120101020202' ), + 'age' => 42, + 'height' => 9000.1, + 'awesome' => true, + 'stuff' => array( 13, 11, 7, 5, 3, 2 ), + 'moarstuff' => (object)array( 'foo' => 'bar', 'bar' => array( 4, 2 ), 'baz' => true ) + ), + true + ), + ); + } + + /** + * @since 1.21 + * @return array + */ + protected function getMockValues() { + return array( + 'id' => 1, + 'str' => 'foobar4645645', + 'int' => 42, + 'float' => 4.2, + 'bool' => '', + 'array' => array( 42, 'foobar' ), + 'blob' => new stdClass() + ); + } +} + +class TestORMRow extends ORMRow { +} + +class TestORMTable extends ORMTable { + + /** + * Returns the name of the database table objects of this type are stored in. + * + * @since 1.20 + * + * @return string + */ + public function getName() { + return 'orm_test'; + } + + /** + * Returns the name of a IORMRow implementing class that + * represents single rows in this table. + * + * @since 1.20 + * + * @return string + */ + public function getRowClass() { + return 'TestORMRow'; + } + + /** + * Returns an array with the fields and their types this object contains. + * This corresponds directly to the fields in the database, without prefix. + * + * field name => type + * + * Allowed types: + * * id + * * str + * * int + * * float + * * bool + * * array + * * blob + * + * @since 1.20 + * + * @return array + */ + public function getFields() { + return array( + 'id' => 'id', + 'name' => 'str', + 'age' => 'int', + 'height' => 'float', + 'awesome' => 'bool', + 'stuff' => 'array', + 'moarstuff' => 'blob', + 'time' => 'str', // TS_MW + ); + } + + /** + * Gets the db field prefix. + * + * @since 1.20 + * + * @return string + */ + protected function getFieldPrefix() { + return 'test_'; + } +} diff --git a/tests/phpunit/includes/debug/MWDebugTest.php b/tests/phpunit/includes/debug/MWDebugTest.php new file mode 100644 index 00000000..6e41de75 --- /dev/null +++ b/tests/phpunit/includes/debug/MWDebugTest.php @@ -0,0 +1,141 @@ +<?php + +class MWDebugTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + // Make sure MWDebug class is enabled + static $MWDebugEnabled = false; + if ( !$MWDebugEnabled ) { + MWDebug::init(); + $MWDebugEnabled = true; + } + /** Clear log before each test */ + MWDebug::clearLog(); + wfSuppressWarnings(); + } + + protected function tearDown() { + wfRestoreWarnings(); + parent::tearDown(); + } + + /** + * @covers MWDebug::log + */ + public function testAddLog() { + MWDebug::log( 'logging a string' ); + $this->assertEquals( + array( array( + 'msg' => 'logging a string', + 'type' => 'log', + 'caller' => __METHOD__, + ) ), + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::warning + */ + public function testAddWarning() { + MWDebug::warning( 'Warning message' ); + $this->assertEquals( + array( array( + 'msg' => 'Warning message', + 'type' => 'warn', + 'caller' => 'MWDebugTest::testAddWarning', + ) ), + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 1, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidNonConsecutivesDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::warning( 'some warning' ); + MWDebug::log( 'we could have logged something too' ); + // Another deprecation + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 3, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::appendDebugInfoToApiResult + */ + public function testAppendDebugInfoToApiResultXmlFormat() { + $request = $this->newApiRequest( + array( 'action' => 'help', 'format' => 'xml' ), + '/api.php?action=help&format=xml' + ); + + $context = new RequestContext(); + $context->setRequest( $request ); + + $apiMain = new ApiMain( $context ); + + $result = new ApiResult( $apiMain ); + $result->setRawMode( true ); + + MWDebug::appendDebugInfoToApiResult( $context, $result ); + + $this->assertInstanceOf( 'ApiResult', $result ); + $data = $result->getData(); + + $expectedKeys = array( 'mwVersion', 'phpEngine', 'phpVersion', 'gitRevision', 'gitBranch', + 'gitViewUrl', 'time', 'log', 'debugLog', 'queries', 'request', 'memory', + 'memoryPeak', 'includes', 'profile', '_element' ); + + foreach ( $expectedKeys as $expectedKey ) { + $this->assertArrayHasKey( $expectedKey, $data['debuginfo'], "debuginfo has $expectedKey" ); + } + + $xml = ApiFormatXml::recXmlPrint( 'help', $data ); + + // exception not thrown + $this->assertInternalType( 'string', $xml ); + } + + /** + * @param string[] $params + * @param string $requestUrl + * + * @return FauxRequest + */ + private function newApiRequest( array $params, $requestUrl ) { + $request = $this->getMockBuilder( 'FauxRequest' ) + ->setMethods( array( 'getRequestURL' ) ) + ->setConstructorArgs( array( + $params + ) ) + ->getMock(); + + $request->expects( $this->any() ) + ->method( 'getRequestURL' ) + ->will( $this->returnValue( $requestUrl ) ); + + return $request; + } + +} diff --git a/tests/phpunit/includes/deferred/DeferredUpdatesTest.php b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php new file mode 100644 index 00000000..5348c854 --- /dev/null +++ b/tests/phpunit/includes/deferred/DeferredUpdatesTest.php @@ -0,0 +1,38 @@ +<?php + +class DeferredUpdatesTest extends MediaWikiTestCase { + + public function testDoUpdates() { + $updates = array( + '1' => 'deferred update 1', + '2' => 'deferred update 2', + '3' => 'deferred update 3', + '2-1' => 'deferred update 1 within deferred update 2', + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['1']; + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2']; + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates['2-1']; + } + ); + } + ); + DeferredUpdates::addCallableUpdate( + function () use ( $updates ) { + echo $updates[3]; + } + ); + + $this->expectOutputString( implode( '', $updates ) ); + + DeferredUpdates::doUpdates(); + } + +} diff --git a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php new file mode 100644 index 00000000..188ad3fd --- /dev/null +++ b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php @@ -0,0 +1,135 @@ +<?php + +/** + * @licence GNU GPL v2+ + * @author Adam Shorland + * + * @group Diff + */ +class ArrayDiffFormatterTest extends MediaWikiTestCase { + + /** + * @param Diff $input + * @param array $expectedOutput + * @dataProvider provideTestFormat + * @covers ArrayDiffFormatter::format + */ + public function testFormat( $input, $expectedOutput ) { + $instance = new ArrayDiffFormatter(); + $output = $instance->format( $input ); + $this->assertEquals( $expectedOutput, $output ); + } + + private function getMockDiff( $edits ) { + $diff = $this->getMockBuilder( 'Diff' ) + ->disableOriginalConstructor() + ->getMock(); + $diff->expects( $this->any() ) + ->method( 'getEdits' ) + ->will( $this->returnValue( $edits ) ); + return $diff; + } + + private function getMockDiffOp( $type = null, $orig = array(), $closing = array() ) { + $diffOp = $this->getMockBuilder( 'DiffOp' ) + ->disableOriginalConstructor() + ->getMock(); + $diffOp->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $type ) ); + $diffOp->expects( $this->any() ) + ->method( 'getOrig' ) + ->will( $this->returnValue( $orig ) ); + if ( $type === 'change' ) { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->with( $this->isType( 'integer' ) ) + ->will( $this->returnCallback( function () { + return 'mockLine'; + } ) ); + } else { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->will( $this->returnValue( $closing ) ); + } + return $diffOp; + } + + public function provideTestFormat() { + $emptyArrayTestCases = array( + $this->getMockDiff( array() ), + $this->getMockDiff( array( $this->getMockDiffOp( 'add' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'delete' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'change' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'copy' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'FOOBARBAZ' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'add', 'line' ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array(), array( 'line' ) ) ) ), + $this->getMockDiff( array( $this->getMockDiffOp( 'copy', array(), array( 'line' ) ) ) ), + ); + + $otherTestCases = array(); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'add', array( ), array( 'a1' ) ) ) ), + array( array( 'action' => 'add', 'new' => 'a1', 'newline' => 1 ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'add', array( ), array( 'a1', 'a2' ) ) ) ), + array( + array( 'action' => 'add', 'new' => 'a1', 'newline' => 1 ), + array( 'action' => 'add', 'new' => 'a2', 'newline' => 2 ), + ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array( 'd1' ) ) ) ), + array( array( 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'delete', array( 'd1', 'd2' ) ) ) ), + array( + array( 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ), + array( 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ), + ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( 'change', array( 'd1' ), array( 'a1' ) ) ) ), + array( array( + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ) ), + ); + $otherTestCases[] = array( + $this->getMockDiff( array( $this->getMockDiffOp( + 'change', + array( 'd1', 'd2' ), + array( 'a1', 'a2' ) + ) ) ), + array( + array( + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ), + array( + 'action' => 'change', + 'old' => 'd2', + 'new' => 'mockLine', + 'newline' => 2, 'oldline' => 2 + ), + ), + ); + + $testCases = array(); + foreach ( $emptyArrayTestCases as $testCase ) { + $testCases[] = array( $testCase, array() ); + } + foreach ( $otherTestCases as $testCase ) { + $testCases[] = array( $testCase[0], $testCase[1] ); + } + return $testCases; + } + +} diff --git a/tests/phpunit/includes/diff/DiffOpTest.php b/tests/phpunit/includes/diff/DiffOpTest.php new file mode 100644 index 00000000..d89b89fe --- /dev/null +++ b/tests/phpunit/includes/diff/DiffOpTest.php @@ -0,0 +1,73 @@ +<?php + +//Load our FakeDiffOp +require_once __DIR__ . DIRECTORY_SEPARATOR . 'FakeDiffOp.php'; + +/** + * @licence GNU GPL v2+ + * @author Adam Shorland + * + * @group Diff + */ +class DiffOpTest extends MediaWikiTestCase { + + /** + * @covers DiffOp::getType + */ + public function testGetType() { + $obj = new FakeDiffOp(); + $obj->type = 'foo'; + $this->assertEquals( 'foo', $obj->getType() ); + } + + /** + * @covers DiffOp::getOrig + */ + public function testGetOrig() { + $obj = new FakeDiffOp(); + $obj->orig = array( 'foo' ); + $this->assertEquals( array( 'foo' ), $obj->getOrig() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosing() { + $obj = new FakeDiffOp(); + $obj->closing = array( 'foo' ); + $this->assertEquals( array( 'foo' ), $obj->getClosing() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosingWithParameter() { + $obj = new FakeDiffOp(); + $obj->closing = array( 'foo', 'bar', 'baz' ); + $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); + $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); + $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); + $this->assertEquals( null, $obj->getClosing( 3 ) ); + } + + /** + * @covers DiffOp::norig + */ + public function testNorig() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->norig() ); + $obj->orig = array( 'foo' ); + $this->assertEquals( 1, $obj->norig() ); + } + + /** + * @covers DiffOp::nclosing + */ + public function testNclosing() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->nclosing() ); + $obj->closing = array( 'foo' ); + $this->assertEquals( 1, $obj->nclosing() ); + } + +} diff --git a/tests/phpunit/includes/diff/DiffTest.php b/tests/phpunit/includes/diff/DiffTest.php new file mode 100644 index 00000000..1911c82a --- /dev/null +++ b/tests/phpunit/includes/diff/DiffTest.php @@ -0,0 +1,20 @@ +<?php + +/** + * @licence GNU GPL v2+ + * @author Adam Shorland + * + * @group Diff + */ +class DiffTest extends MediaWikiTestCase { + + /** + * @covers Diff::getEdits + */ + public function testGetEdits() { + $obj = new Diff( array(), array() ); + $obj->edits = 'FooBarBaz'; + $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); + } + +} diff --git a/tests/phpunit/includes/diff/DifferenceEngineTest.php b/tests/phpunit/includes/diff/DifferenceEngineTest.php new file mode 100644 index 00000000..5474b963 --- /dev/null +++ b/tests/phpunit/includes/diff/DifferenceEngineTest.php @@ -0,0 +1,121 @@ +<?php + +/** + * @covers DifferenceEngine + * + * @todo tests for the rest of DifferenceEngine! + * + * @group Database + * @group Diff + * + * @licence GNU GPL v2+ + * @author Katie Filbert < aude.wiki@gmail.com > + */ +class DifferenceEngineTest extends MediaWikiTestCase { + + protected $context; + + private static $revisions; + + protected function setUp() { + parent::setUp(); + + $title = $this->getTitle(); + + $this->context = new RequestContext(); + $this->context->setTitle( $title ); + + if ( !self::$revisions ) { + self::$revisions = $this->doEdits(); + } + } + + /** + * @return Title + */ + protected function getTitle() { + $namespace = $this->getDefaultWikitextNS(); + return Title::newFromText( 'Kitten', $namespace ); + } + + /** + * @return int[] Revision ids + */ + protected function doEdits() { + $title = $this->getTitle(); + $page = WikiPage::factory( $title ); + + $strings = array( "it is a kitten", "two kittens", "three kittens", "four kittens" ); + $revisions = array(); + + foreach ( $strings as $string ) { + $content = ContentHandler::makeContent( $string, $title ); + $page->doEditContent( $content, 'edit page' ); + $revisions[] = $page->getLatest(); + } + + return $revisions; + } + + public function testMapDiffPrevNext() { + $cases = $this->getMapDiffPrevNextCases(); + + foreach ( $cases as $case ) { + list( $expected, $old, $new, $message ) = $case; + + $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false ); + $diffMap = $diffEngine->mapDiffPrevNext( $old, $new ); + $this->assertEquals( $expected, $diffMap, $message ); + } + } + + private function getMapDiffPrevNextCases() { + $revs = self::$revisions; + + return array( + array( array( $revs[1], $revs[2] ), $revs[2], 'prev', 'diff=prev' ), + array( array( $revs[2], $revs[3] ), $revs[2], 'next', 'diff=next' ), + array( array( $revs[1], $revs[3] ), $revs[1], $revs[3], 'diff=' . $revs[3] ) + ); + } + + public function testLoadRevisionData() { + $cases = $this->getLoadRevisionDataCases(); + + foreach ( $cases as $case ) { + list( $expectedOld, $expectedNew, $old, $new, $message ) = $case; + + $diffEngine = new DifferenceEngine( $this->context, $old, $new, 2, true, false ); + $diffEngine->loadRevisionData(); + + $this->assertEquals( $diffEngine->getOldid(), $expectedOld, $message ); + $this->assertEquals( $diffEngine->getNewid(), $expectedNew, $message ); + } + } + + private function getLoadRevisionDataCases() { + $revs = self::$revisions; + + return array( + array( $revs[2], $revs[3], $revs[3], 'prev', 'diff=prev' ), + array( $revs[2], $revs[3], $revs[2], 'next', 'diff=next' ), + array( $revs[1], $revs[3], $revs[1], $revs[3], 'diff=' . $revs[3] ), + array( $revs[1], $revs[3], $revs[1], 0, 'diff=0' ) + ); + } + + public function testGetOldid() { + $revs = self::$revisions; + + $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false ); + $this->assertEquals( $revs[1], $diffEngine->getOldid(), 'diff get old id' ); + } + + public function testGetNewid() { + $revs = self::$revisions; + + $diffEngine = new DifferenceEngine( $this->context, $revs[1], $revs[2], 2, true, false ); + $this->assertEquals( $revs[2], $diffEngine->getNewid(), 'diff get new id' ); + } + +} diff --git a/tests/phpunit/includes/diff/FakeDiffOp.php b/tests/phpunit/includes/diff/FakeDiffOp.php new file mode 100644 index 00000000..70c8f64a --- /dev/null +++ b/tests/phpunit/includes/diff/FakeDiffOp.php @@ -0,0 +1,11 @@ +<?php + +/** + * Class FakeDiffOp used to test abstract class DiffOp + */ +class FakeDiffOp extends DiffOp { + + public function reverse() { + return null; + } +} diff --git a/tests/phpunit/includes/exception/BadTitleErrorTest.php b/tests/phpunit/includes/exception/BadTitleErrorTest.php new file mode 100644 index 00000000..003efd27 --- /dev/null +++ b/tests/phpunit/includes/exception/BadTitleErrorTest.php @@ -0,0 +1,43 @@ +<?php +/** + * @covers BadTitleError + * @author Adam Shorland + */ +class BadTitleErrorTest extends MediaWikiTestCase { + + protected $wgOut; + + protected function setUp() { + parent::setUp(); + global $wgOut; + $this->wgOut = clone $wgOut; + } + + protected function tearDown() { + parent::tearDown(); + global $wgOut; + $wgOut = $this->wgOut; + } + + public function testExceptionSetsStatusCode() { + global $wgOut; + $wgOut = $this->getMockWgOut(); + try { + throw new BadTitleError(); + } catch ( BadTitleError $e ) { + $e->report(); + $this->assertTrue( true ); + } + } + + private function getMockWgOut() { + $mock = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->once() ) + ->method( 'setStatusCode' ) + ->with( 400 ); + return $mock; + } + +} diff --git a/tests/phpunit/includes/exception/ErrorPageErrorTest.php b/tests/phpunit/includes/exception/ErrorPageErrorTest.php new file mode 100644 index 00000000..13dcf33b --- /dev/null +++ b/tests/phpunit/includes/exception/ErrorPageErrorTest.php @@ -0,0 +1,67 @@ +<?php + +/** + * @covers ErrorPageError + * @author Adam Shorland + */ +class ErrorPageErrorTest extends MediaWikiTestCase { + + private $wgOut; + + protected function setUp() { + parent::setUp(); + global $wgOut; + $this->wgOut = clone $wgOut; + } + + protected function tearDown() { + global $wgOut; + $wgOut = $this->wgOut; + parent::tearDown(); + } + + private function getMockMessage() { + $mockMessage = $this->getMockBuilder( 'Message' ) + ->disableOriginalConstructor() + ->getMock(); + $mockMessage->expects( $this->once() ) + ->method( 'inLanguage' ) + ->will( $this->returnValue( $mockMessage ) ); + $mockMessage->expects( $this->once() ) + ->method( 'useDatabase' ) + ->will( $this->returnValue( $mockMessage ) ); + return $mockMessage; + } + + public function testConstruction() { + $mockMessage = $this->getMockMessage(); + $title = 'Foo'; + $params = array( 'Baz' ); + $e = new ErrorPageError( $title, $mockMessage, $params ); + $this->assertEquals( $title, $e->title ); + $this->assertEquals( $mockMessage, $e->msg ); + $this->assertEquals( $params, $e->params ); + } + + public function testReport() { + $mockMessage = $this->getMockMessage(); + $title = 'Foo'; + $params = array( 'Baz' ); + + global $wgOut; + $wgOut = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $wgOut->expects( $this->once() ) + ->method( 'showErrorPage' ) + ->with( $title, $mockMessage, $params ); + $wgOut->expects( $this->once() ) + ->method( 'output' ); + + $e = new ErrorPageError( $title, $mockMessage, $params ); + $e->report(); + } + + + +} diff --git a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php new file mode 100644 index 00000000..dc5dc6aa --- /dev/null +++ b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php @@ -0,0 +1,74 @@ +<?php +/** + * @author Antoine Musso + * @copyright Copyright © 2013, Antoine Musso + * @copyright Copyright © 2013, Wikimedia Foundation Inc. + * @file + */ + +class MWExceptionHandlerTest extends MediaWikiTestCase { + + /** + * @covers MWExceptionHandler::getRedactedTrace + */ + public function testGetRedactedTrace() { + $refvar = 'value'; + try { + $array = array( 'a', 'b' ); + $object = new StdClass(); + self::helperThrowAnException( $array, $object, $refvar ); + } catch ( Exception $e ) { + } + + # Make sure our stack trace contains an array and an object passed to + # some function in the stacktrace. Else, we can not assert the trace + # redaction achieved its job. + $trace = $e->getTrace(); + $hasObject = false; + $hasArray = false; + foreach ( $trace as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $hasObject = $hasObject || is_object( $arg ); + $hasArray = $hasArray || is_array( $arg ); + } + + if ( $hasObject && $hasArray ) { + break; + } + } + $this->assertTrue( $hasObject, + "The stacktrace must have a function having an object has parameter" ); + $this->assertTrue( $hasArray, + "The stacktrace must have a function having an array has parameter" ); + + # Now we redact the trace.. and make sure no function arguments are + # arrays or objects. + $redacted = MWExceptionHandler::getRedactedTrace( $e ); + + foreach ( $redacted as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $this->assertNotInternalType( 'array', $arg ); + $this->assertNotInternalType( 'object', $arg ); + } + } + + $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); + } + + /** + * Helper function for testExpandArgumentsInCall + * + * Pass it an object and an array, and something by reference :-) + * + * @throws Exception + */ + protected static function helperThrowAnException( $a, $b, &$c ) { + throw new Exception(); + } +} diff --git a/tests/phpunit/includes/exception/MWExceptionTest.php b/tests/phpunit/includes/exception/MWExceptionTest.php new file mode 100644 index 00000000..ef0f2a9e --- /dev/null +++ b/tests/phpunit/includes/exception/MWExceptionTest.php @@ -0,0 +1,241 @@ +<?php +/** + * @author Antoine Musso + * @copyright Copyright © 2013, Antoine Musso + * @copyright Copyright © 2013, Wikimedia Foundation Inc. + * @file + */ + +class MWExceptionTest extends MediaWikiTestCase { + + /** + * @expectedException MWException + */ + public function testMwexceptionThrowing() { + throw new MWException(); + } + + /** + * @dataProvider provideTextUseOutputPage + * @covers MWException::useOutputPage + */ + public function testUseOutputPage( $expected, $wgLang, $wgFullyInitialised, $wgOut ) { + $this->setMwGlobals( array( + 'wgLang' => $wgLang, + 'wgFullyInitialised' => $wgFullyInitialised, + 'wgOut' => $wgOut, + ) ); + + $e = new MWException(); + $this->assertEquals( $expected, $e->useOutputPage() ); + } + + public function provideTextUseOutputPage() { + return array( + // expected, wgLang, wgFullyInitialised, wgOut + array( false, null, null, null ), + array( false, $this->getMockLanguage(), null, null ), + array( false, $this->getMockLanguage(), true, null ), + array( false, null, true, null ), + array( false, null, null, true ), + array( true, $this->getMockLanguage(), true, true ), + ); + } + + private function getMockLanguage() { + return $this->getMockBuilder( 'Language' ) + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @dataProvider provideUseMessageCache + * @covers MWException::useMessageCache + */ + public function testUseMessageCache( $expected, $wgLang ) { + $this->setMwGlobals( array( + 'wgLang' => $wgLang, + ) ); + $e = new MWException(); + $this->assertEquals( $expected, $e->useMessageCache() ); + } + + public function provideUseMessageCache() { + return array( + array( false, null ), + array( true, $this->getMockLanguage() ), + ); + } + + /** + * @covers MWException::isLoggable + */ + public function testIsLogable() { + $e = new MWException(); + $this->assertTrue( $e->isLoggable() ); + } + + /** + * @dataProvider provideRunHooks + * @covers MWException::runHooks + */ + public function testRunHooks( $wgExceptionHooks, $name, $args, $expectedReturn ) { + $this->setMwGlobals( array( + 'wgExceptionHooks' => $wgExceptionHooks, + ) ); + $e = new MWException(); + $this->assertEquals( $expectedReturn, $e->runHooks( $name, $args ) ); + } + + public static function provideRunHooks() { + return array( + array( null, null, null, null ), + array( array(), 'name', array(), null ), + array( array( 'name' => false ), 'name', array(), null ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array(), 'YAY.[]' + ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array( 'a' ), 'YAY.{"1":"a"}' + ), + array( + array( 'mockHook' => array( 'MWExceptionTest::mockHook' ) ), + 'mockHook', array( null ), null + ), + ); + } + + /** + * Used in conjunction with provideRunHooks and testRunHooks as a mock callback for a hook + */ + public static function mockHook() { + $args = func_get_args(); + if ( !$args[0] instanceof MWException ) { + return '$caller not instance of MWException'; + } + unset( $args[0] ); + if ( array_key_exists( 1, $args ) && $args[1] === null ) { + return null; + } + return 'YAY.' . json_encode( $args ); + } + + /** + * @dataProvider provideIsCommandLine + * @covers MWException::isCommandLine + */ + public function testisCommandLine( $expected, $wgCommandLineMode ) { + $this->setMwGlobals( array( + 'wgCommandLineMode' => $wgCommandLineMode, + ) ); + $e = new MWException(); + $this->assertEquals( $expected, $e->isCommandLine() ); + } + + public static function provideIsCommandLine() { + return array( + array( false, null ), + array( true, true ), + ); + } + + /** + * Verify the exception classes are JSON serializabe. + * + * @covers MWExceptionHandler::jsonSerializeException + * @dataProvider provideExceptionClasses + */ + public function testJsonSerializeExceptions( $exception_class ) { + $json = MWExceptionHandler::jsonSerializeException( + new $exception_class() + ); + $this->assertNotEquals( false, $json, + "The $exception_class exception should be JSON serializable, got false." ); + } + + public static function provideExceptionClasses() { + return array( + array( 'Exception' ), + array( 'MWException' ), + ); + } + + /** + * Lame JSON schema validation. + * + * @covers MWExceptionHandler::jsonSerializeException + * + * @param string $expectedKeyType Type expected as returned by gettype() + * @param string $exClass An exception class (ie: Exception, MWException) + * @param string $key Name of the key to validate in the serialized JSON + * @dataProvider provideJsonSerializedKeys + */ + public function testJsonserializeexceptionKeys( $expectedKeyType, $exClass, $key ) { + + # Make sure we log a backtrace: + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => true ) ); + + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new $exClass()) + ); + $this->assertObjectHasAttribute( $key, $json, + "JSON serialized exception is missing key '$key'" + ); + $this->assertInternalType( $expectedKeyType, $json->$key, + "JSON serialized key '$key' has type " . gettype( $json->$key ) + . " (expected: $expectedKeyType)." + ); + } + + /** + * Returns test cases: exception class, key name, gettype() + */ + public static function provideJsonSerializedKeys() { + $testCases = array(); + foreach ( array( 'Exception', 'MWException' ) as $exClass ) { + $exTests = array( + array( 'string', $exClass, 'id' ), + array( 'string', $exClass, 'file' ), + array( 'integer', $exClass, 'line' ), + array( 'string', $exClass, 'message' ), + array( 'null', $exClass, 'url' ), + # Backtrace only enabled with wgLogExceptionBacktrace = true + array( 'array', $exClass, 'backtrace' ), + ); + $testCases = array_merge( $testCases, $exTests ); + } + return $testCases; + } + + /** + * Given wgLogExceptionBacktrace is true + * then serialized exception SHOULD have a backtrace + * + * @covers MWExceptionHandler::jsonSerializeException + */ + public function testJsonserializeexceptionBacktracingEnabled() { + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => true ) ); + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new Exception() ) + ); + $this->assertObjectHasAttribute( 'backtrace', $json ); + } + + /** + * Given wgLogExceptionBacktrace is false + * then serialized exception SHOULD NOT have a backtrace + * + * @covers MWExceptionHandler::jsonSerializeException + */ + public function testJsonserializeexceptionBacktracingDisabled() { + $this->setMwGlobals( array( 'wgLogExceptionBacktrace' => false ) ); + $json = json_decode( + MWExceptionHandler::jsonSerializeException( new Exception() ) + ); + $this->assertObjectNotHasAttribute( 'backtrace', $json ); + + } + +} diff --git a/tests/phpunit/includes/exception/ReadOnlyErrorTest.php b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php new file mode 100644 index 00000000..6f6aba47 --- /dev/null +++ b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php @@ -0,0 +1,16 @@ +<?php + +/** + * @covers ReadOnlyError + * @author Adam Shorland + */ +class ReadOnlyErrorTest extends MediaWikiTestCase { + + public function testConstruction() { + $e = new ReadOnlyError(); + $this->assertEquals( 'readonly', $e->title ); + $this->assertEquals( 'readonlytext', $e->msg ); + $this->assertEquals( wfReadOnlyReason() ?: array(), $e->params ); + } + +} diff --git a/tests/phpunit/includes/exception/ThrottledErrorTest.php b/tests/phpunit/includes/exception/ThrottledErrorTest.php new file mode 100644 index 00000000..bdb143fa --- /dev/null +++ b/tests/phpunit/includes/exception/ThrottledErrorTest.php @@ -0,0 +1,44 @@ +<?php + +/** + * @covers ThrottledError + * @author Adam Shorland + */ +class ThrottledErrorTest extends MediaWikiTestCase { + + protected $wgOut; + + protected function setUp() { + parent::setUp(); + global $wgOut; + $this->wgOut = clone $wgOut; + } + + protected function tearDown() { + parent::tearDown(); + global $wgOut; + $wgOut = $this->wgOut; + } + + public function testExceptionSetsStatusCode() { + global $wgOut; + $wgOut = $this->getMockWgOut(); + try { + throw new ThrottledError(); + } catch ( ThrottledError $e ) { + $e->report(); + $this->assertTrue( true ); + } + } + + private function getMockWgOut() { + $mock = $this->getMockBuilder( 'OutputPage' ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->once() ) + ->method( 'setStatusCode' ) + ->with( 429 ); + return $mock; + } + +} diff --git a/tests/phpunit/includes/exception/UserNotLoggedInTest.php b/tests/phpunit/includes/exception/UserNotLoggedInTest.php new file mode 100644 index 00000000..591a0fa1 --- /dev/null +++ b/tests/phpunit/includes/exception/UserNotLoggedInTest.php @@ -0,0 +1,16 @@ +<?php + +/** + * @covers UserNotLoggedIn + * @author Adam Shorland + */ +class UserNotLoggedInTest extends MediaWikiTestCase { + + public function testConstruction() { + $e = new UserNotLoggedIn(); + $this->assertEquals( 'exception-nologin', $e->title ); + $this->assertEquals( 'exception-nologin-text', $e->msg ); + $this->assertEquals( array(), $e->params ); + } + +} diff --git a/tests/phpunit/includes/filebackend/FileBackendTest.php b/tests/phpunit/includes/filebackend/FileBackendTest.php new file mode 100644 index 00000000..9558cc7d --- /dev/null +++ b/tests/phpunit/includes/filebackend/FileBackendTest.php @@ -0,0 +1,2472 @@ +<?php + +/** + * @group FileRepo + * @group FileBackend + * @group medium + */ +class FileBackendTest extends MediaWikiTestCase { + + /** @var FileBackend */ + private $backend; + /** @var FileBackendMultiWrite */ + private $multiBackend; + /** @var FSFileBackend */ + public $singleBackend; + private $filesToPrune = array(); + private static $backendToUse; + + protected function setUp() { + global $wgFileBackends; + parent::setUp(); + $uniqueId = time() . '-' . mt_rand(); + $tmpPrefix = wfTempDir() . '/filebackend-unittest-' . $uniqueId; + if ( $this->getCliArg( 'use-filebackend' ) ) { + if ( self::$backendToUse ) { + $this->singleBackend = self::$backendToUse; + } else { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + break; + } + } + $useConfig['name'] = 'localtesting'; // swap name + $useConfig['shardViaHashLevels'] = array( // test sharding + 'unittest-cont1' => array( 'levels' => 1, 'base' => 16, 'repeat' => 1 ) + ); + if ( isset( $useConfig['fileJournal'] ) ) { + $useConfig['fileJournal'] = FileJournal::factory( $useConfig['fileJournal'], $name ); + } + $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] ); + $class = $useConfig['class']; + self::$backendToUse = new $class( $useConfig ); + $this->singleBackend = self::$backendToUse; + } + } else { + $this->singleBackend = new FSFileBackend( array( + 'name' => 'localtesting', + 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ), + 'wikiId' => wfWikiID(), + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtesting-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtesting-cont2" ) + ) ); + } + $this->multiBackend = new FileBackendMultiWrite( array( + 'name' => 'localtesting', + 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ), + 'parallelize' => 'implicit', + 'wikiId' => wfWikiId() . $uniqueId, + 'backends' => array( + array( + 'name' => 'localmultitesting1', + 'class' => 'FSFileBackend', + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtestingmulti1-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtestingmulti1-cont2" ), + 'isMultiMaster' => false + ), + array( + 'name' => 'localmultitesting2', + 'class' => 'FSFileBackend', + 'containerPaths' => array( + 'unittest-cont1' => "{$tmpPrefix}-localtestingmulti2-cont1", + 'unittest-cont2' => "{$tmpPrefix}-localtestingmulti2-cont2" ), + 'isMultiMaster' => true + ) + ) + ) ); + $this->filesToPrune = array(); + } + + private static function baseStorePath() { + return 'mwstore://localtesting'; + } + + private function backendClass() { + return get_class( $this->backend ); + } + + /** + * @dataProvider provider_testIsStoragePath + * @covers FileBackend::isStoragePath + */ + public function testIsStoragePath( $path, $isStorePath ) { + $this->assertEquals( $isStorePath, FileBackend::isStoragePath( $path ), + "FileBackend::isStoragePath on path '$path'" ); + } + + public static function provider_testIsStoragePath() { + return array( + array( 'mwstore://', true ), + array( 'mwstore://backend', true ), + array( 'mwstore://backend/container', true ), + array( 'mwstore://backend/container/', true ), + array( 'mwstore://backend/container/path', true ), + array( 'mwstore://backend//container/', true ), + array( 'mwstore://backend//container//', true ), + array( 'mwstore://backend//container//path', true ), + array( 'mwstore:///', true ), + array( 'mwstore:/', false ), + array( 'mwstore:', false ), + ); + } + + /** + * @dataProvider provider_testSplitStoragePath + * @covers FileBackend::splitStoragePath + */ + public function testSplitStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::splitStoragePath( $path ), + "FileBackend::splitStoragePath on path '$path'" ); + } + + public static function provider_testSplitStoragePath() { + return array( + array( 'mwstore://backend/container', array( 'backend', 'container', '' ) ), + array( 'mwstore://backend/container/', array( 'backend', 'container', '' ) ), + array( 'mwstore://backend/container/path', array( 'backend', 'container', 'path' ) ), + array( 'mwstore://backend/container//path', array( 'backend', 'container', '/path' ) ), + array( 'mwstore://backend//container/path', array( null, null, null ) ), + array( 'mwstore://backend//container//path', array( null, null, null ) ), + array( 'mwstore://', array( null, null, null ) ), + array( 'mwstore://backend', array( null, null, null ) ), + array( 'mwstore:///', array( null, null, null ) ), + array( 'mwstore:/', array( null, null, null ) ), + array( 'mwstore:', array( null, null, null ) ) + ); + } + + /** + * @dataProvider provider_normalizeStoragePath + * @covers FileBackend::normalizeStoragePath + */ + public function testNormalizeStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::normalizeStoragePath( $path ), + "FileBackend::normalizeStoragePath on path '$path'" ); + } + + public static function provider_normalizeStoragePath() { + return array( + array( 'mwstore://backend/container', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/path', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container//path', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container///path', 'mwstore://backend/container/path' ), + array( + 'mwstore://backend/container///path//to///obj', + 'mwstore://backend/container/path/to/obj' + ), + array( 'mwstore://', null ), + array( 'mwstore://backend', null ), + array( 'mwstore://backend//container/path', null ), + array( 'mwstore://backend//container//path', null ), + array( 'mwstore:///', null ), + array( 'mwstore:/', null ), + array( 'mwstore:', null ), + ); + } + + /** + * @dataProvider provider_testParentStoragePath + * @covers FileBackend::parentStoragePath + */ + public function testParentStoragePath( $path, $res ) { + $this->assertEquals( $res, FileBackend::parentStoragePath( $path ), + "FileBackend::parentStoragePath on path '$path'" ); + } + + public static function provider_testParentStoragePath() { + return array( + array( 'mwstore://backend/container/path/to/obj', 'mwstore://backend/container/path/to' ), + array( 'mwstore://backend/container/path/to', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container/path', 'mwstore://backend/container' ), + array( 'mwstore://backend/container', null ), + array( 'mwstore://backend/container/path/to/obj/', 'mwstore://backend/container/path/to' ), + array( 'mwstore://backend/container/path/to/', 'mwstore://backend/container/path' ), + array( 'mwstore://backend/container/path/', 'mwstore://backend/container' ), + array( 'mwstore://backend/container/', null ), + ); + } + + /** + * @dataProvider provider_testExtensionFromPath + * @covers FileBackend::extensionFromPath + */ + public function testExtensionFromPath( $path, $res ) { + $this->assertEquals( $res, FileBackend::extensionFromPath( $path ), + "FileBackend::extensionFromPath on path '$path'" ); + } + + public static function provider_testExtensionFromPath() { + return array( + array( 'mwstore://backend/container/path.txt', 'txt' ), + array( 'mwstore://backend/container/path.svg.png', 'png' ), + array( 'mwstore://backend/container/path', '' ), + array( 'mwstore://backend/container/path.', '' ), + ); + } + + /** + * @dataProvider provider_testStore + */ + public function testStore( $op ) { + $this->filesToPrune[] = $op['src']; + + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStore( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStore( $op ); + $this->filesToPrune[] = $op['src']; # avoid file leaking + $this->tearDownFiles(); + } + + /** + * @covers FileBackend::doOperation + */ + private function doTestStore( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + file_put_contents( $source, "Unit test file" ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->store( $op ); + } + + $status = $this->backend->doOperation( $op ); + + $this->assertGoodStatus( $status, + "Store from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Store from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Store from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( true, file_exists( $source ), + "Source file $source still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists ($backendName)." ); + + $this->assertEquals( filesize( $source ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = FSFile::getPropsFromPath( $source ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( $props1, $props2, + "Source and destination have the same props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $dest ) ); + } + + public static function provider_testStore() { + $cases = array(); + + $tmpName = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + $toPath = self::baseStorePath() . '/unittest-cont1/e/fun/obj1.txt'; + $op = array( 'op' => 'store', 'src' => $tmpName, 'dst' => $toPath ); + $cases[] = array( + $op, // operation + $tmpName, // source + $toPath, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $tmpName, // source + $toPath, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $tmpName, // source + $toPath, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testCopy + * @covers FileBackend::doOperation + */ + public function testCopy( $op ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestCopy( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestCopy( $op ); + $this->tearDownFiles(); + } + + private function doTestCopy( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + if ( isset( $op['ignoreMissingSource'] ) ) { + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest does not exist ($backendName)." ); + + return; // done + } + + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->copy( $op ); + } + + $status = $this->backend->doOperation( $op ); + + $this->assertGoodStatus( $status, + "Copy from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Copy from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Copy from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after copy ($backendName)." ); + + $this->assertEquals( + $this->backend->getFileSize( array( 'src' => $source ) ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( $props1, $props2, + "Source and destination have the same props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source, $dest ) ); + } + + public static function provider_testCopy() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/file.txt'; + $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt'; + + $op = array( 'op' => 'copy', 'src' => $source, 'dst' => $dest ); + $cases[] = array( + $op, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source + $dest, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testMove + * @covers FileBackend::doOperation + */ + public function testMove( $op ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestMove( $op ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestMove( $op ); + $this->tearDownFiles(); + } + + private function doTestMove( $op ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + if ( isset( $op['ignoreMissingSource'] ) ) { + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest does not exist ($backendName)." ); + + return; // done + } + + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + if ( isset( $op['overwrite'] ) || isset( $op['overwriteSame'] ) ) { + $this->backend->copy( $op ); + } + + $status = $this->backend->doOperation( $op ); + $this->assertGoodStatus( $status, + "Move from $source to $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Move from $source to $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Move from $source to $dest has proper 'success' field in Status ($backendName)." ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not still exists ($backendName)." ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after move ($backendName)." ); + + $this->assertNotEquals( + $this->backend->getFileSize( array( 'src' => $source ) ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $props2 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( false, $props1['fileExists'], + "Source file does not exist accourding to props ($backendName)." ); + $this->assertEquals( true, $props2['fileExists'], + "Destination file exists accourding to props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source, $dest ) ); + } + + public static function provider_testMove() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/file.txt'; + $dest = self::baseStorePath() . '/unittest-cont2/a/fileMoved.txt'; + + $op = array( 'op' => 'move', 'src' => $source, 'dst' => $dest ); + $cases[] = array( + $op, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + $source, // source + $dest, // dest + ); + + $op2 = $op; + $op2['ignoreMissingSource'] = true; + $cases[] = array( + $op2, // operation + self::baseStorePath() . '/unittest-cont-bad/e/file.txt', // source + $dest, // dest + ); + + return $cases; + } + + /** + * @dataProvider provider_testDelete + * @covers FileBackend::doOperation + */ + public function testDelete( $op, $withSource, $okStatus ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDelete( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDelete( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + } + + private function doTestDelete( $op, $withSource, $okStatus ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + + if ( $withSource ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Deletion of file at $source succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Deletion of file at $source succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Deletion of file at $source has proper 'success' field in Status ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Deletion of file at $source failed ($backendName)." ); + } + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $source ) ), + "Source file $source does not exist after move ($backendName)." ); + + $this->assertFalse( + $this->backend->getFileSize( array( 'src' => $source ) ), + "Source file $source has correct size (false) ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $source ) ); + $this->assertFalse( $props1['fileExists'], + "Source file $source does not exist according to props ($backendName)." ); + + $this->assertBackendPathsConsistent( array( $source ) ); + } + + public static function provider_testDelete() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt'; + + $op = array( 'op' => 'delete', 'src' => $source ); + $cases[] = array( + $op, // operation + true, // with source + true // succeeds + ); + + $cases[] = array( + $op, // operation + false, // without source + false // fails + ); + + $op['ignoreMissingSource'] = true; + $cases[] = array( + $op, // operation + false, // without source + true // succeeds + ); + + $op['ignoreMissingSource'] = true; + $op['src'] = self::baseStorePath() . '/unittest-cont-bad/e/file.txt'; + $cases[] = array( + $op, // operation + false, // without source + true // succeeds + ); + + return $cases; + } + + /** + * @dataProvider provider_testDescribe + * @covers FileBackend::doOperation + */ + public function testDescribe( $op, $withSource, $okStatus ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDescribe( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDescribe( $op, $withSource, $okStatus ); + $this->tearDownFiles(); + } + + private function doTestDescribe( $op, $withSource, $okStatus ) { + $backendName = $this->backendClass(); + + $source = $op['src']; + $this->prepare( array( 'dir' => dirname( $source ) ) ); + + if ( $withSource ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => 'blahblah', 'dst' => $source, + 'headers' => array( 'Content-Disposition' => 'xxx' ) ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertHasHeaders( array( 'Content-Disposition' => 'xxx' ), $attr ); + } + + $status = $this->backend->describe( array( 'src' => $source, + 'headers' => array( 'Content-Disposition' => '' ) ) ); // remove + $this->assertGoodStatus( $status, + "Removal of header for $source succeeded ($backendName)." ); + + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertFalse( isset( $attr['headers']['content-disposition'] ), + "File 'Content-Disposition' header removed." ); + } + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Describe of file at $source succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Describe of file at $source succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Describe of file at $source has proper 'success' field in Status ($backendName)." ); + if ( $this->backend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { + $attr = $this->backend->getFileXAttributes( array( 'src' => $source ) ); + $this->assertHasHeaders( $op['headers'], $attr ); + } + } else { + $this->assertEquals( false, $status->isOK(), + "Describe of file at $source failed ($backendName)." ); + } + + $this->assertBackendPathsConsistent( array( $source ) ); + } + + private function assertHasHeaders( array $headers, array $attr ) { + foreach ( $headers as $n => $v ) { + if ( $n !== '' ) { + $this->assertTrue( isset( $attr['headers'][strtolower( $n )] ), + "File has '$n' header." ); + $this->assertEquals( $v, $attr['headers'][strtolower( $n )], + "File has '$n' header value." ); + } else { + $this->assertFalse( isset( $attr['headers'][strtolower( $n )] ), + "File does not have '$n' header." ); + } + } + } + + public static function provider_testDescribe() { + $cases = array(); + + $source = self::baseStorePath() . '/unittest-cont1/e/myfacefile.txt'; + + $op = array( 'op' => 'describe', 'src' => $source, + 'headers' => array( 'Content-Disposition' => 'inline' ), ); + $cases[] = array( + $op, // operation + true, // with source + true // succeeds + ); + + $cases[] = array( + $op, // operation + false, // without source + false // fails + ); + + return $cases; + } + + /** + * @dataProvider provider_testCreate + * @covers FileBackend::doOperation + */ + public function testCreate( $op, $alreadyExists, $okStatus, $newSize ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestCreate( $op, $alreadyExists, $okStatus, $newSize ); + $this->tearDownFiles(); + } + + private function doTestCreate( $op, $alreadyExists, $okStatus, $newSize ) { + $backendName = $this->backendClass(); + + $dest = $op['dst']; + $this->prepare( array( 'dir' => dirname( $dest ) ) ); + + $oldText = 'blah...blah...waahwaah'; + if ( $alreadyExists ) { + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $oldText, 'dst' => $dest ) ); + $this->assertGoodStatus( $status, + "Creation of file at $dest succeeded ($backendName)." ); + } + + $status = $this->backend->doOperation( $op ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Creation of file at $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of file at $dest succeeded ($backendName)." ); + $this->assertEquals( array( 0 => true ), $status->success, + "Creation of file at $dest has proper 'success' field in Status ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Creation of file at $dest failed ($backendName)." ); + } + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $dest ) ), + "Destination file $dest exists after creation ($backendName)." ); + + $props1 = $this->backend->getFileProps( array( 'src' => $dest ) ); + $this->assertEquals( true, $props1['fileExists'], + "Destination file $dest exists according to props ($backendName)." ); + if ( $okStatus ) { // file content is what we saved + $this->assertEquals( $newSize, $props1['size'], + "Destination file $dest has expected size according to props ($backendName)." ); + $this->assertEquals( $newSize, + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has correct size ($backendName)." ); + } else { // file content is some other previous text + $this->assertEquals( strlen( $oldText ), $props1['size'], + "Destination file $dest has original size according to props ($backendName)." ); + $this->assertEquals( strlen( $oldText ), + $this->backend->getFileSize( array( 'src' => $dest ) ), + "Destination file $dest has original size according to props ($backendName)." ); + } + + $this->assertBackendPathsConsistent( array( $dest ) ); + } + + /** + * @dataProvider provider_testCreate + */ + public static function provider_testCreate() { + $cases = array(); + + $dest = self::baseStorePath() . '/unittest-cont2/a/myspacefile.txt'; + + $op = array( 'op' => 'create', 'content' => 'test test testing', 'dst' => $dest ); + $cases[] = array( + $op, // operation + false, // no dest already exists + true, // succeeds + strlen( $op['content'] ) + ); + + $op2 = $op; + $op2['content'] = "\n"; + $cases[] = array( + $op2, // operation + false, // no dest already exists + true, // succeeds + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "fsf\n waf 3kt"; + $cases[] = array( + $op2, // operation + true, // dest already exists + false, // fails + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "egm'g gkpe gpqg eqwgwqg"; + $op2['overwrite'] = true; + $cases[] = array( + $op2, // operation + true, // dest already exists + true, // succeeds + strlen( $op2['content'] ) + ); + + $op2 = $op; + $op2['content'] = "39qjmg3-qg"; + $op2['overwriteSame'] = true; + $cases[] = array( + $op2, // operation + true, // dest already exists + false, // succeeds + strlen( $op2['content'] ) + ); + + return $cases; + } + + /** + * @covers FileBackend::doQuickOperations + */ + public function testDoQuickOperations() { + $this->backend = $this->singleBackend; + $this->doTestDoQuickOperations(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestDoQuickOperations(); + $this->tearDownFiles(); + } + + private function doTestDoQuickOperations() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $files = array( + "$base/unittest-cont1/e/fileA.a", + "$base/unittest-cont1/e/fileB.a", + "$base/unittest-cont1/e/fileC.a" + ); + $createOps = array(); + $purgeOps = array(); + foreach ( $files as $path ) { + $status = $this->prepare( array( 'dir' => dirname( $path ) ) ); + $this->assertGoodStatus( $status, + "Preparing $path succeeded without warnings ($backendName)." ); + $createOps[] = array( 'op' => 'create', 'dst' => $path, 'content' => mt_rand( 0, 50000 ) ); + $copyOps[] = array( 'op' => 'copy', 'src' => $path, 'dst' => "$path-2" ); + $moveOps[] = array( 'op' => 'move', 'src' => "$path-2", 'dst' => "$path-3" ); + $purgeOps[] = array( 'op' => 'delete', 'src' => $path ); + $purgeOps[] = array( 'op' => 'delete', 'src' => "$path-3" ); + } + $purgeOps[] = array( 'op' => 'null' ); + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $createOps ), + "Creation of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => $file ) ), + "File $file exists." ); + } + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $copyOps ), + "Quick copy of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => "$file-2" ) ), + "File $file-2 exists." ); + } + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $moveOps ), + "Quick move of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertTrue( $this->backend->fileExists( array( 'src' => "$file-3" ) ), + "File $file-3 move in." ); + $this->assertFalse( $this->backend->fileExists( array( 'src' => "$file-2" ) ), + "File $file-2 moved away." ); + } + + $this->assertGoodStatus( + $this->backend->quickCopy( array( 'src' => $files[0], 'dst' => $files[0] ) ), + "Copy of file {$files[0]} over itself succeeded ($backendName)." ); + $this->assertTrue( $this->backend->fileExists( array( 'src' => $files[0] ) ), + "File {$files[0]} still exists." ); + + $this->assertGoodStatus( + $this->backend->quickMove( array( 'src' => $files[0], 'dst' => $files[0] ) ), + "Move of file {$files[0]} over itself succeeded ($backendName)." ); + $this->assertTrue( $this->backend->fileExists( array( 'src' => $files[0] ) ), + "File {$files[0]} still exists." ); + + $this->assertGoodStatus( + $this->backend->doQuickOperations( $purgeOps ), + "Quick deletion of source files succeeded ($backendName)." ); + foreach ( $files as $file ) { + $this->assertFalse( $this->backend->fileExists( array( 'src' => $file ) ), + "File $file purged." ); + $this->assertFalse( $this->backend->fileExists( array( 'src' => "$file-3" ) ), + "File $file-3 purged." ); + } + } + + /** + * @dataProvider provider_testConcatenate + */ + public function testConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ) { + $this->filesToPrune[] = $op['dst']; + + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ); + $this->filesToPrune[] = $op['dst']; # avoid file leaking + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestConcatenate( $op, $srcs, $srcsContent, $alreadyExists, $okStatus ); + $this->filesToPrune[] = $op['dst']; # avoid file leaking + $this->tearDownFiles(); + } + + private function doTestConcatenate( $params, $srcs, $srcsContent, $alreadyExists, $okStatus ) { + $backendName = $this->backendClass(); + + $expContent = ''; + // Create sources + $ops = array(); + foreach ( $srcs as $i => $source ) { + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $ops[] = array( + 'op' => 'create', // operation + 'dst' => $source, // source + 'content' => $srcsContent[$i] + ); + $expContent .= $srcsContent[$i]; + } + $status = $this->backend->doOperations( $ops ); + + $this->assertGoodStatus( $status, + "Creation of source files succeeded ($backendName)." ); + + $dest = $params['dst']; + if ( $alreadyExists ) { + $ok = file_put_contents( $dest, 'blah...blah...waahwaah' ) !== false; + $this->assertEquals( true, $ok, + "Creation of file at $dest succeeded ($backendName)." ); + } else { + $ok = file_put_contents( $dest, '' ) !== false; + $this->assertEquals( true, $ok, + "Creation of 0-byte file at $dest succeeded ($backendName)." ); + } + + // Combine the files into one + $status = $this->backend->concatenate( $params ); + if ( $okStatus ) { + $this->assertGoodStatus( $status, + "Creation of concat file at $dest succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of concat file at $dest succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Creation of concat file at $dest failed ($backendName)." ); + } + + if ( $okStatus ) { + $this->assertEquals( true, is_file( $dest ), + "Dest concat file $dest exists after creation ($backendName)." ); + } else { + $this->assertEquals( true, is_file( $dest ), + "Dest concat file $dest exists after failed creation ($backendName)." ); + } + + $contents = file_get_contents( $dest ); + $this->assertNotEquals( false, $contents, "File at $dest exists ($backendName)." ); + + if ( $okStatus ) { + $this->assertEquals( $expContent, $contents, + "Concat file at $dest has correct contents ($backendName)." ); + } else { + $this->assertNotEquals( $expContent, $contents, + "Concat file at $dest has correct contents ($backendName)." ); + } + } + + public static function provider_testConcatenate() { + $cases = array(); + + $rand = mt_rand( 0, 2000000000 ) . time(); + $dest = wfTempDir() . "/randomfile!$rand.txt"; + $srcs = array( + self::baseStorePath() . '/unittest-cont1/e/file1.txt', + self::baseStorePath() . '/unittest-cont1/e/file2.txt', + self::baseStorePath() . '/unittest-cont1/e/file3.txt', + self::baseStorePath() . '/unittest-cont1/e/file4.txt', + self::baseStorePath() . '/unittest-cont1/e/file5.txt', + self::baseStorePath() . '/unittest-cont1/e/file6.txt', + self::baseStorePath() . '/unittest-cont1/e/file7.txt', + self::baseStorePath() . '/unittest-cont1/e/file8.txt', + self::baseStorePath() . '/unittest-cont1/e/file9.txt', + self::baseStorePath() . '/unittest-cont1/e/file10.txt' + ); + $content = array( + 'egfage', + 'ageageag', + 'rhokohlr', + 'shgmslkg', + 'kenga', + 'owagmal', + 'kgmae', + 'g eak;g', + 'lkaem;a', + 'legma' + ); + $params = array( 'srcs' => $srcs, 'dst' => $dest ); + + $cases[] = array( + $params, // operation + $srcs, // sources + $content, // content for each source + false, // no dest already exists + true, // succeeds + ); + + $cases[] = array( + $params, // operation + $srcs, // sources + $content, // content for each source + true, // dest already exists + false, // succeeds + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileStat + * @covers FileBackend::getFileStat + */ + public function testGetFileStat( $path, $content, $alreadyExists ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileStat( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileStat( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + } + + private function doTestGetFileStat( $path, $content, $alreadyExists ) { + $backendName = $this->backendClass(); + + if ( $alreadyExists ) { + $this->prepare( array( 'dir' => dirname( $path ) ) ); + $status = $this->create( array( 'dst' => $path, 'content' => $content ) ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + $time = $this->backend->getFileTimestamp( array( 'src' => $path ) ); + $stat = $this->backend->getFileStat( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10, + "Correct file timestamp of '$path'" ); + + $size = $stat['size']; + $time = $stat['mtime']; + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + $this->assertTrue( abs( time() - wfTimestamp( TS_UNIX, $time ) ) < 10, + "Correct file timestamp of '$path'" ); + + $this->backend->clearCache( array( $path ) ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + + $this->backend->preloadCache( array( $path ) ); + + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + + $this->assertEquals( strlen( $content ), $size, + "Correct file size of '$path'" ); + } else { + $size = $this->backend->getFileSize( array( 'src' => $path ) ); + $time = $this->backend->getFileTimestamp( array( 'src' => $path ) ); + $stat = $this->backend->getFileStat( array( 'src' => $path ) ); + + $this->assertFalse( $size, "Correct file size of '$path'" ); + $this->assertFalse( $time, "Correct file timestamp of '$path'" ); + $this->assertFalse( $stat, "Correct file stat of '$path'" ); + } + } + + public static function provider_testGetFileStat() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents", true ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", "", true ); + $cases[] = array( "$base/unittest-cont1/e/b/some-diff_file.txt", null, false ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileStat + * @covers FileBackend::streamFile + */ + public function testStreamFile( $path, $content, $alreadyExists ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStreamFile( $path, $content, $alreadyExists ); + $this->tearDownFiles(); + } + + private function doTestStreamFile( $path, $content ) { + $backendName = $this->backendClass(); + + // Test doStreamFile() directly to avoid header madness + $class = new ReflectionClass( $this->backend ); + $method = $class->getMethod( 'doStreamFile' ); + $method->setAccessible( true ); + + if ( $content !== null ) { + $this->prepare( array( 'dir' => dirname( $path ) ) ); + $status = $this->create( array( 'dst' => $path, 'content' => $content ) ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + ob_start(); + $method->invokeArgs( $this->backend, array( array( 'src' => $path ) ) ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( $content, $data, "Correct content streamed from '$path'" ); + } else { // 404 case + ob_start(); + $method->invokeArgs( $this->backend, array( array( 'src' => $path ) ) ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( '', $data, "Correct content streamed from '$path' ($backendName)" ); + } + } + + public static function provider_testStreamFile() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", null ); + + return $cases; + } + + /** + * @dataProvider provider_testGetFileContents + * @covers FileBackend::getFileContents + * @covers FileBackend::getFileContentsMulti + */ + public function testGetFileContents( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileContents( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileContents( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetFileContents( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $contents = $this->backend->getFileContentsMulti( array( 'srcs' => $source ) ); + foreach ( $contents as $path => $data ) { + $this->assertNotEquals( false, $data, "Contents of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $data, + "Contents of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $contents ), + "Contents in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $contents ), + "Contents array size correct ($backendName)." + ); + } else { + $data = $this->backend->getFileContents( array( 'src' => $source ) ); + $this->assertNotEquals( false, $data, "Contents of $source exists ($backendName)." ); + $this->assertEquals( $content[0], $data, "Contents of $source is correct ($backendName)." ); + } + } + + public static function provider_testGetFileContents() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/b/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/b/some-other_file.txt", "more file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx", "contents xy", "contents xz" ) + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetLocalCopy + * @covers FileBackend::getLocalCopy + */ + public function testGetLocalCopy( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopy( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopy( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetLocalCopy( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $tmpFiles = $this->backend->getLocalCopyMulti( array( 'srcs' => $source ) ); + foreach ( $tmpFiles as $path => $tmpFile ) { + $this->assertNotNull( $tmpFile, + "Creation of local copy of $path succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local copy of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $contents, + "Local copy of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $tmpFiles ), + "Local copies in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $tmpFiles ), + "Local copies array size correct ($backendName)." + ); + } else { + $tmpFile = $this->backend->getLocalCopy( array( 'src' => $source ) ); + $this->assertNotNull( $tmpFile, + "Creation of local copy of $source succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local copy of $source exists ($backendName)." ); + $this->assertEquals( + $content[0], + $contents, + "Local copy of $source is correct ($backendName)." + ); + } + + $obj = new stdClass(); + $tmpFile->bind( $obj ); + } + + public static function provider_testGetLocalCopy() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx $", "contents xy 111", "contents xz" ) + ); + + return $cases; + } + + /** + * @dataProvider provider_testGetLocalReference + * @covers FileBackend::getLocalReference + */ + public function testGetLocalReference( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalReference( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalReference( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetLocalReference( $source, $content ) { + $backendName = $this->backendClass(); + + $srcs = (array)$source; + $content = (array)$content; + foreach ( $srcs as $i => $src ) { + $this->prepare( array( 'dir' => dirname( $src ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content[$i], 'dst' => $src ) ); + $this->assertGoodStatus( $status, + "Creation of file at $src succeeded ($backendName)." ); + } + + if ( is_array( $source ) ) { + $tmpFiles = $this->backend->getLocalReferenceMulti( array( 'srcs' => $source ) ); + foreach ( $tmpFiles as $path => $tmpFile ) { + $this->assertNotNull( $tmpFile, + "Creation of local copy of $path succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local ref of $path exists ($backendName)." ); + $this->assertEquals( + current( $content ), + $contents, + "Local ref of $path is correct ($backendName)." + ); + next( $content ); + } + $this->assertEquals( + $source, + array_keys( $tmpFiles ), + "Local refs in right order ($backendName)." + ); + $this->assertEquals( + count( $source ), + count( $tmpFiles ), + "Local refs array size correct ($backendName)." + ); + } else { + $tmpFile = $this->backend->getLocalReference( array( 'src' => $source ) ); + $this->assertNotNull( $tmpFile, + "Creation of local copy of $source succeeded ($backendName)." ); + $contents = file_get_contents( $tmpFile->getPath() ); + $this->assertNotEquals( false, $contents, "Local ref of $source exists ($backendName)." ); + $this->assertEquals( $content[0], $contents, "Local ref of $source is correct ($backendName)." ); + } + } + + public static function provider_testGetLocalReference() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + $cases[] = array( + array( "$base/unittest-cont1/e/a/x.txt", "$base/unittest-cont1/e/a/y.txt", + "$base/unittest-cont1/e/a/z.txt" ), + array( "contents xx 1111", "contents xy %", "contents xz $" ) + ); + + return $cases; + } + + /** + * @covers FileBackend::getLocalCopy + * @covers FileBackend::getLocalReference + */ + public function testGetLocalCopyAndReference404() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopyAndReference404(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetLocalCopyAndReference404(); + $this->tearDownFiles(); + } + + public function doTestGetLocalCopyAndReference404() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + + $tmpFile = $this->backend->getLocalCopy( array( + 'src' => "$base/unittest-cont1/not-there" ) ); + $this->assertEquals( null, $tmpFile, "Local copy of not existing file is null ($backendName)." ); + + $tmpFile = $this->backend->getLocalReference( array( + 'src' => "$base/unittest-cont1/not-there" ) ); + $this->assertEquals( null, $tmpFile, "Local ref of not existing file is null ($backendName)." ); + } + + /** + * @dataProvider provider_testGetFileHttpUrl + * @covers FileBackend::getFileHttpUrl + */ + public function testGetFileHttpUrl( $source, $content ) { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileHttpUrl( $source, $content ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileHttpUrl( $source, $content ); + $this->tearDownFiles(); + } + + private function doTestGetFileHttpUrl( $source, $content ) { + $backendName = $this->backendClass(); + + $this->prepare( array( 'dir' => dirname( $source ) ) ); + $status = $this->backend->doOperation( + array( 'op' => 'create', 'content' => $content, 'dst' => $source ) ); + $this->assertGoodStatus( $status, + "Creation of file at $source succeeded ($backendName)." ); + + $url = $this->backend->getFileHttpUrl( array( 'src' => $source ) ); + + if ( $url !== null ) { // supported + $data = Http::request( "GET", $url ); + $this->assertEquals( $content, $data, + "HTTP GET of URL has right contents ($backendName)." ); + } + } + + public static function provider_testGetFileHttpUrl() { + $cases = array(); + + $base = self::baseStorePath(); + $cases[] = array( "$base/unittest-cont1/e/a/z/some_file.txt", "some file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/some-other_file.txt", "more file contents" ); + $cases[] = array( "$base/unittest-cont1/e/a/\$odd&.txt", "test file contents" ); + + return $cases; + } + + /** + * @dataProvider provider_testPrepareAndClean + * @covers FileBackend::prepare + * @covers FileBackend::clean + */ + public function testPrepareAndClean( $path, $isOK ) { + $this->backend = $this->singleBackend; + $this->doTestPrepareAndClean( $path, $isOK ); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestPrepareAndClean( $path, $isOK ); + $this->tearDownFiles(); + } + + public static function provider_testPrepareAndClean() { + $base = self::baseStorePath(); + + return array( + array( "$base/unittest-cont1/e/a/z/some_file1.txt", true ), + array( "$base/unittest-cont2/a/z/some_file2.txt", true ), + # Specific to FS backend with no basePath field set + #array( "$base/unittest-cont3/a/z/some_file3.txt", false ), + ); + } + + private function doTestPrepareAndClean( $path, $isOK ) { + $backendName = $this->backendClass(); + + $status = $this->prepare( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Preparing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Preparing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Preparing dir $path failed ($backendName)." ); + } + + $status = $this->backend->secure( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Securing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Securing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Securing dir $path failed ($backendName)." ); + } + + $status = $this->backend->publish( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Publishing dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Publishing dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Publishing dir $path failed ($backendName)." ); + } + + $status = $this->backend->clean( array( 'dir' => dirname( $path ) ) ); + if ( $isOK ) { + $this->assertGoodStatus( $status, + "Cleaning dir $path succeeded without warnings ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Cleaning dir $path succeeded ($backendName)." ); + } else { + $this->assertEquals( false, $status->isOK(), + "Cleaning dir $path failed ($backendName)." ); + } + } + + public function testRecursiveClean() { + $this->backend = $this->singleBackend; + $this->doTestRecursiveClean(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->doTestRecursiveClean(); + $this->tearDownFiles(); + } + + /** + * @covers FileBackend::clean + */ + private function doTestRecursiveClean() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $dirs = array( + "$base/unittest-cont1", + "$base/unittest-cont1/e", + "$base/unittest-cont1/e/a", + "$base/unittest-cont1/e/a/b", + "$base/unittest-cont1/e/a/b/c", + "$base/unittest-cont1/e/a/b/c/d0", + "$base/unittest-cont1/e/a/b/c/d1", + "$base/unittest-cont1/e/a/b/c/d2", + "$base/unittest-cont1/e/a/b/c/d0/1", + "$base/unittest-cont1/e/a/b/c/d0/2", + "$base/unittest-cont1/e/a/b/c/d1/3", + "$base/unittest-cont1/e/a/b/c/d1/4", + "$base/unittest-cont1/e/a/b/c/d2/5", + "$base/unittest-cont1/e/a/b/c/d2/6" + ); + foreach ( $dirs as $dir ) { + $status = $this->prepare( array( 'dir' => $dir ) ); + $this->assertGoodStatus( $status, + "Preparing dir $dir succeeded without warnings ($backendName)." ); + } + + if ( $this->backend instanceof FSFileBackend ) { + foreach ( $dirs as $dir ) { + $this->assertEquals( true, $this->backend->directoryExists( array( 'dir' => $dir ) ), + "Dir $dir exists ($backendName)." ); + } + } + + $status = $this->backend->clean( + array( 'dir' => "$base/unittest-cont1", 'recursive' => 1 ) ); + $this->assertGoodStatus( $status, + "Recursive cleaning of dir $dir succeeded without warnings ($backendName)." ); + + foreach ( $dirs as $dir ) { + $this->assertEquals( false, $this->backend->directoryExists( array( 'dir' => $dir ) ), + "Dir $dir no longer exists ($backendName)." ); + } + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperations() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperations(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperations(); + $this->tearDownFiles(); + } + + private function doTestDoOperations() { + $base = self::baseStorePath(); + + $fileA = "$base/unittest-cont1/e/a/b/fileA.txt"; + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileB = "$base/unittest-cont1/e/a/b/fileB.txt"; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileC = "$base/unittest-cont1/e/a/b/fileC.txt"; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + $fileD = "$base/unittest-cont1/e/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->create( array( 'dst' => $fileB, 'content' => $fileBContents ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->create( array( 'dst' => $fileC, 'content' => $fileCContents ) ); + $this->prepare( array( 'dir' => dirname( $fileD ) ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'describe', 'src' => $fileA, + 'headers' => array( 'X-Content-Length' => '91.3' ), 'disposition' => 'inline' ), + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<empty>, D:<A> + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ), + // Now: A:<A>, B:<empty>, C:<B>, D:<A> + array( 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:<B>, B:<empty>, C:<empty>, D:<empty> + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ), + // Now: A:<B>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:<empty>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'null' ), + // Does nothing + ) ); + + $this->assertGoodStatus( $status, "Operation batch succeeded" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 14, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileC ) ), + "Correct file contents of $fileC" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileC ) ), + "Correct file size of $fileC" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileC ) ), + "Correct file SHA-1 of $fileC" ); + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperationsPipeline() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsPipeline(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsPipeline(); + $this->tearDownFiles(); + } + + // concurrency orientated + private function doTestDoOperationsPipeline() { + $base = self::baseStorePath(); + + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + + $tmpNameA = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameA, $fileAContents ); + $tmpNameB = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameB, $fileBContents ); + $tmpNameC = TempFSFile::factory( "unittests_", 'txt' )->getPath(); + file_put_contents( $tmpNameC, $fileCContents ); + + $this->filesToPrune[] = $tmpNameA; # avoid file leaking + $this->filesToPrune[] = $tmpNameB; # avoid file leaking + $this->filesToPrune[] = $tmpNameC; # avoid file leaking + + $fileA = "$base/unittest-cont1/e/a/b/fileA.txt"; + $fileB = "$base/unittest-cont1/e/a/b/fileB.txt"; + $fileC = "$base/unittest-cont1/e/a/b/fileC.txt"; + $fileD = "$base/unittest-cont1/e/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->prepare( array( 'dir' => dirname( $fileD ) ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'store', 'src' => $tmpNameA, 'dst' => $fileA, 'overwriteSame' => 1 ), + array( 'op' => 'store', 'src' => $tmpNameB, 'dst' => $fileB, 'overwrite' => 1 ), + array( 'op' => 'store', 'src' => $tmpNameC, 'dst' => $fileC, 'overwrite' => 1 ), + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<empty>, D:<A> + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC ), + // Now: A:<A>, B:<empty>, C:<B>, D:<A> + array( 'op' => 'move', 'src' => $fileD, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:<B>, B:<empty>, C:<empty>, D:<empty> + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC ), + // Now: A:<B>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'move', 'src' => $fileA, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:<empty>, B:<empty>, C:<B>, D:<empty> + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwrite' => 1 ), + // Does nothing + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Does nothing + array( 'op' => 'null' ), + // Does nothing + ) ); + + $this->assertGoodStatus( $status, "Operation batch succeeded" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 16, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileC ) ), + "Correct file contents of $fileC" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileC ) ), + "Correct file size of $fileC" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileC ) ), + "Correct file SHA-1 of $fileC" ); + } + + /** + * @covers FileBackend::doOperations + */ + public function testDoOperationsFailing() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsFailing(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestDoOperationsFailing(); + $this->tearDownFiles(); + } + + private function doTestDoOperationsFailing() { + $base = self::baseStorePath(); + + $fileA = "$base/unittest-cont2/a/b/fileA.txt"; + $fileAContents = '3tqtmoeatmn4wg4qe-mg3qt3 tq'; + $fileB = "$base/unittest-cont2/a/b/fileB.txt"; + $fileBContents = 'g-jmq3gpqgt3qtg q3GT '; + $fileC = "$base/unittest-cont2/a/b/fileC.txt"; + $fileCContents = 'eigna[ogmewt 3qt g3qg flew[ag'; + $fileD = "$base/unittest-cont2/a/b/fileD.txt"; + + $this->prepare( array( 'dir' => dirname( $fileA ) ) ); + $this->create( array( 'dst' => $fileA, 'content' => $fileAContents ) ); + $this->prepare( array( 'dir' => dirname( $fileB ) ) ); + $this->create( array( 'dst' => $fileB, 'content' => $fileBContents ) ); + $this->prepare( array( 'dir' => dirname( $fileC ) ) ); + $this->create( array( 'dst' => $fileC, 'content' => $fileCContents ) ); + + $status = $this->backend->doOperations( array( + array( 'op' => 'copy', 'src' => $fileA, 'dst' => $fileC, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> (file:<orginal contents>) + array( 'op' => 'copy', 'src' => $fileC, 'dst' => $fileA, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> + array( 'op' => 'copy', 'src' => $fileB, 'dst' => $fileD, 'overwrite' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<B> + array( 'op' => 'move', 'src' => $fileC, 'dst' => $fileD ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed) + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileC, 'overwriteSame' => 1 ), + // Now: A:<A>, B:<B>, C:<A>, D:<empty> (failed) + array( 'op' => 'move', 'src' => $fileB, 'dst' => $fileA, 'overwrite' => 1 ), + // Now: A:<B>, B:<empty>, C:<A>, D:<empty> + array( 'op' => 'delete', 'src' => $fileD ), + // Now: A:<B>, B:<empty>, C:<A>, D:<empty> + array( 'op' => 'null' ), + // Does nothing + ), array( 'force' => 1 ) ); + + $this->assertNotEquals( array(), $status->errors, "Operation had warnings" ); + $this->assertEquals( true, $status->isOK(), "Operation batch succeeded" ); + $this->assertEquals( 8, count( $status->success ), + "Operation batch has correct success array" ); + + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileB ) ), + "File does not exist at $fileB" ); + $this->assertEquals( false, $this->backend->fileExists( array( 'src' => $fileD ) ), + "File does not exist at $fileD" ); + + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileA ) ), + "File does not exist at $fileA" ); + $this->assertEquals( true, $this->backend->fileExists( array( 'src' => $fileC ) ), + "File exists at $fileC" ); + $this->assertEquals( $fileBContents, + $this->backend->getFileContents( array( 'src' => $fileA ) ), + "Correct file contents of $fileA" ); + $this->assertEquals( strlen( $fileBContents ), + $this->backend->getFileSize( array( 'src' => $fileA ) ), + "Correct file size of $fileA" ); + $this->assertEquals( wfBaseConvert( sha1( $fileBContents ), 16, 36, 31 ), + $this->backend->getFileSha1Base36( array( 'src' => $fileA ) ), + "Correct file SHA-1 of $fileA" ); + } + + /** + * @covers FileBackend::getFileList + */ + public function testGetFileList() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetFileList(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetFileList(); + $this->tearDownFiles(); + } + + private function doTestGetFileList() { + $backendName = $this->backendClass(); + $base = self::baseStorePath(); + + // Should have no errors + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont-notexists" ) ); + + $files = array( + "$base/unittest-cont1/e/test1.txt", + "$base/unittest-cont1/e/test2.txt", + "$base/unittest-cont1/e/test3.txt", + "$base/unittest-cont1/e/subdir1/test1.txt", + "$base/unittest-cont1/e/subdir1/test2.txt", + "$base/unittest-cont1/e/subdir2/test3.txt", + "$base/unittest-cont1/e/subdir2/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test1.txt", + "$base/unittest-cont1/e/subdir2/subdir/test2.txt", + "$base/unittest-cont1/e/subdir2/subdir/test3.txt", + "$base/unittest-cont1/e/subdir2/subdir/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test5.txt", + "$base/unittest-cont1/e/subdir2/subdir/sub/test0.txt", + "$base/unittest-cont1/e/subdir2/subdir/sub/120-px-file.txt", + ); + + // Add the files + $ops = array(); + foreach ( $files as $file ) { + $this->prepare( array( 'dir' => dirname( $file ) ) ); + $ops[] = array( 'op' => 'create', 'content' => 'xxy', 'dst' => $file ); + } + $status = $this->backend->doQuickOperations( $ops ); + $this->assertGoodStatus( $status, + "Creation of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of files succeeded with OK status ($backendName)." ); + + // Expected listing at root + $expected = array( + "e/test1.txt", + "e/test2.txt", + "e/test3.txt", + "e/subdir1/test1.txt", + "e/subdir1/test2.txt", + "e/subdir2/test3.txt", + "e/subdir2/test4.txt", + "e/subdir2/subdir/test1.txt", + "e/subdir2/subdir/test2.txt", + "e/subdir2/subdir/test3.txt", + "e/subdir2/subdir/test4.txt", + "e/subdir2/subdir/test5.txt", + "e/subdir2/subdir/sub/test0.txt", + "e/subdir2/subdir/sub/120-px-file.txt", + ); + sort( $expected ); + + // Actual listing (no trailing slash) at root + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (no trailing slash) at root with advise + $iter = $this->backend->getFileList( array( + 'dir' => "$base/unittest-cont1", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (with trailing slash) at root + $list = array(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Expected listing at subdir + $expected = array( + "test1.txt", + "test2.txt", + "test3.txt", + "test4.txt", + "test5.txt", + "sub/test0.txt", + "sub/120-px-file.txt", + ); + sort( $expected ); + + // Actual listing (no trailing slash) at subdir + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (no trailing slash) at subdir with advise + $iter = $this->backend->getFileList( array( + 'dir' => "$base/unittest-cont1/e/subdir2/subdir", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (with trailing slash) at subdir + $list = array(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName)." ); + + // Actual listing (using iterator second time) + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct file listing ($backendName), second iteration." ); + + // Actual listing (top files only) at root + $iter = $this->backend->getTopFileList( array( 'dir' => "$base/unittest-cont1" ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( array(), $list, "Correct top file listing ($backendName)." ); + + // Expected listing (top files only) at subdir + $expected = array( + "test1.txt", + "test2.txt", + "test3.txt", + "test4.txt", + "test5.txt" + ); + sort( $expected ); + + // Actual listing (top files only) at subdir + $iter = $this->backend->getTopFileList( + array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) + ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." ); + + // Actual listing (top files only) at subdir with advise + $iter = $this->backend->getTopFileList( array( + 'dir' => "$base/unittest-cont1/e/subdir2/subdir", + 'adviseStat' => 1 + ) ); + $list = $this->listToArray( $iter ); + sort( $list ); + $this->assertEquals( $expected, $list, "Correct top file listing ($backendName)." ); + + foreach ( $files as $file ) { // clean up + $this->backend->doOperation( array( 'op' => 'delete', 'src' => $file ) ); + } + + $iter = $this->backend->getFileList( array( 'dir' => "$base/unittest-cont1/not/exists" ) ); + foreach ( $iter as $iter ) { + // no errors + } + } + + /** + * @covers FileBackend::getTopDirectoryList + * @covers FileBackend::getDirectoryList + */ + public function testGetDirectoryList() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestGetDirectoryList(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestGetDirectoryList(); + $this->tearDownFiles(); + } + + private function doTestGetDirectoryList() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $files = array( + "$base/unittest-cont1/e/test1.txt", + "$base/unittest-cont1/e/test2.txt", + "$base/unittest-cont1/e/test3.txt", + "$base/unittest-cont1/e/subdir1/test1.txt", + "$base/unittest-cont1/e/subdir1/test2.txt", + "$base/unittest-cont1/e/subdir2/test3.txt", + "$base/unittest-cont1/e/subdir2/test4.txt", + "$base/unittest-cont1/e/subdir2/subdir/test1.txt", + "$base/unittest-cont1/e/subdir3/subdir/test2.txt", + "$base/unittest-cont1/e/subdir4/subdir/test3.txt", + "$base/unittest-cont1/e/subdir4/subdir/test4.txt", + "$base/unittest-cont1/e/subdir4/subdir/test5.txt", + "$base/unittest-cont1/e/subdir4/subdir/sub/test0.txt", + "$base/unittest-cont1/e/subdir4/subdir/sub/120-px-file.txt", + ); + + // Add the files + $ops = array(); + foreach ( $files as $file ) { + $this->prepare( array( 'dir' => dirname( $file ) ) ); + $ops[] = array( 'op' => 'create', 'content' => 'xxy', 'dst' => $file ); + } + $status = $this->backend->doQuickOperations( $ops ); + $this->assertGoodStatus( $status, + "Creation of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Creation of files succeeded with OK status ($backendName)." ); + + $this->assertEquals( true, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir1" ) ), + "Directory exists in ($backendName)." ); + $this->assertEquals( true, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir2/subdir" ) ), + "Directory exists in ($backendName)." ); + $this->assertEquals( false, + $this->backend->directoryExists( array( 'dir' => "$base/unittest-cont1/e/subdir2/test1.txt" ) ), + "Directory does not exists in ($backendName)." ); + + // Expected listing + $expected = array( + "e", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Expected listing + $expected = array( + "subdir1", + "subdir2", + "subdir3", + "subdir4", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (with trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Expected listing + $expected = array( + "subdir", + ); + sort( $expected ); + + // Actual listing (no trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir2" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (with trailing slash) + $list = array(); + $iter = $this->backend->getTopDirectoryList( + array( 'dir' => "$base/unittest-cont1/e/subdir2/" ) + ); + + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct top dir listing ($backendName)." ); + + // Actual listing (using iterator second time) + $list = array(); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( + $expected, + $list, + "Correct top dir listing ($backendName), second iteration." + ); + + // Expected listing (recursive) + $expected = array( + "e", + "e/subdir1", + "e/subdir2", + "e/subdir3", + "e/subdir4", + "e/subdir2/subdir", + "e/subdir3/subdir", + "e/subdir4/subdir", + "e/subdir4/subdir/sub", + ); + sort( $expected ); + + // Actual listing (recursive) + $list = array(); + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + // Expected listing (recursive) + $expected = array( + "subdir", + "subdir/sub", + ); + sort( $expected ); + + // Actual listing (recursive) + $list = array(); + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir4" ) ); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + // Actual listing (recursive, second time) + $list = array(); + foreach ( $iter as $file ) { + $list[] = $file; + } + sort( $list ); + + $this->assertEquals( $expected, $list, "Correct dir listing ($backendName)." ); + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/subdir1" ) ); + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + + foreach ( $files as $file ) { // clean up + $this->backend->doOperation( array( 'op' => 'delete', 'src' => $file ) ); + } + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/not/exists" ) ); + foreach ( $iter as $file ) { + // no errors + } + + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + + $iter = $this->backend->getDirectoryList( array( 'dir' => "$base/unittest-cont1/e/not/exists" ) ); + $items = $this->listToArray( $iter ); + $this->assertEquals( array(), $items, "Directory listing is empty." ); + } + + /** + * @covers FileBackend::lockFiles + * @covers FileBackend::unlockFiles + */ + public function testLockCalls() { + $this->backend = $this->singleBackend; + $this->doTestLockCalls(); + } + + private function doTestLockCalls() { + $backendName = $this->backendClass(); + + $paths = array( + "test1.txt", + "test2.txt", + "test3.txt", + "subdir1", + "subdir1", // duplicate + "subdir1/test1.txt", + "subdir1/test2.txt", + "subdir2", + "subdir2", // duplicate + "subdir2/test3.txt", + "subdir2/test4.txt", + "subdir2/subdir", + "subdir2/subdir/test1.txt", + "subdir2/subdir/test2.txt", + "subdir2/subdir/test3.txt", + "subdir2/subdir/test4.txt", + "subdir2/subdir/test5.txt", + "subdir2/subdir/sub", + "subdir2/subdir/sub/test0.txt", + "subdir2/subdir/sub/120-px-file.txt", + ); + + for ( $i = 0; $i < 25; $i++ ) { + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName). ($i)" ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + ## Flip the acquire/release ordering around ## + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->lockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_EX ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName). ($i)" ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + + $status = $this->backend->unlockFiles( $paths, LockManager::LOCK_SH ); + $this->assertEquals( print_r( array(), true ), print_r( $status->errors, true ), + "Locking of files succeeded ($backendName) ($i)." ); + $this->assertEquals( true, $status->isOK(), + "Locking of files succeeded with OK status ($backendName) ($i)." ); + } + + $status = Status::newGood(); + $sl = $this->backend->getScopedFileLocks( $paths, LockManager::LOCK_EX, $status ); + $this->assertType( 'ScopedLock', $sl, + "Scoped locking of files succeeded ($backendName)." ); + $this->assertEquals( array(), $status->errors, + "Scoped locking of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Scoped locking of files succeeded with OK status ($backendName)." ); + + ScopedLock::release( $sl ); + $this->assertEquals( null, $sl, + "Scoped unlocking of files succeeded ($backendName)." ); + $this->assertEquals( array(), $status->errors, + "Scoped unlocking of files succeeded ($backendName)." ); + $this->assertEquals( true, $status->isOK(), + "Scoped unlocking of files succeeded with OK status ($backendName)." ); + } + + // helper function + private function listToArray( $iter ) { + return is_array( $iter ) ? $iter : iterator_to_array( $iter ); + } + + // test helper wrapper for backend prepare() function + private function prepare( array $params ) { + return $this->backend->prepare( $params ); + } + + // test helper wrapper for backend prepare() function + private function create( array $params ) { + $params['op'] = 'create'; + + return $this->backend->doQuickOperations( array( $params ) ); + } + + function tearDownFiles() { + foreach ( $this->filesToPrune as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + $containers = array( 'unittest-cont1', 'unittest-cont2', 'unittest-cont-bad' ); + foreach ( $containers as $container ) { + $this->deleteFiles( $container ); + } + $this->filesToPrune = array(); + } + + private function deleteFiles( $container ) { + $base = self::baseStorePath(); + $iter = $this->backend->getFileList( array( 'dir' => "$base/$container" ) ); + if ( $iter ) { + foreach ( $iter as $file ) { + $this->backend->quickDelete( array( 'src' => "$base/$container/$file" ) ); + } + // free the directory, to avoid Permission denied under windows on rmdir + unset( $iter ); + } + $this->backend->clean( array( 'dir' => "$base/$container", 'recursive' => 1 ) ); + } + + function assertBackendPathsConsistent( array $paths ) { + if ( $this->backend instanceof FileBackendMultiWrite ) { + $status = $this->backend->consistencyCheck( $paths ); + $this->assertGoodStatus( $status, "Files synced: " . implode( ',', $paths ) ); + } + } + + function assertGoodStatus( $status, $msg ) { + $this->assertEquals( print_r( array(), 1 ), print_r( $status->errors, 1 ), $msg ); + } +} diff --git a/tests/phpunit/includes/filerepo/FileRepoTest.php b/tests/phpunit/includes/filerepo/FileRepoTest.php new file mode 100644 index 00000000..a196dca8 --- /dev/null +++ b/tests/phpunit/includes/filerepo/FileRepoTest.php @@ -0,0 +1,55 @@ +<?php + +class FileRepoTest extends MediaWikiTestCase { + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionCanNotBeNull() { + new FileRepo(); + } + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionCanNotBeAnEmptyArray() { + new FileRepo( array() ); + } + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionNeedNameKey() { + new FileRepo( array( + 'backend' => 'foobar' + ) ); + } + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionNeedBackendKey() { + new FileRepo( array( + 'name' => 'foobar' + ) ); + } + + /** + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionWithRequiredOptions() { + $f = new FileRepo( array( + 'name' => 'FileRepoTestRepository', + 'backend' => new FSFileBackend( array( + 'name' => 'local-testing', + 'wikiId' => 'test_wiki', + 'containerPaths' => array() + ) ) + ) ); + $this->assertInstanceOf( 'FileRepo', $f ); + } +} diff --git a/tests/phpunit/includes/filerepo/RepoGroupTest.php b/tests/phpunit/includes/filerepo/RepoGroupTest.php new file mode 100644 index 00000000..5bdb7e7f --- /dev/null +++ b/tests/phpunit/includes/filerepo/RepoGroupTest.php @@ -0,0 +1,59 @@ +<?php +class RepoGroupTest extends MediaWikiTestCase { + + function testHasForeignRepoNegative() { + $this->setMwGlobals( 'wgForeignFileRepos', array() ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + $this->assertFalse( RepoGroup::singleton()->hasForeignRepos() ); + } + + function testHasForeignRepoPositive() { + $this->setUpForeignRepo(); + $this->assertTrue( RepoGroup::singleton()->hasForeignRepos() ); + } + + function testForEachForeignRepo() { + $this->setUpForeignRepo(); + $fakeCallback = $this->getMock( 'RepoGroupTestHelper' ); + $fakeCallback->expects( $this->once() )->method( 'callback' ); + RepoGroup::singleton()->forEachForeignRepo( + array( $fakeCallback, 'callback' ), array( array() ) ); + } + + function testForEachForeignRepoNone() { + $this->setMwGlobals( 'wgForeignFileRepos', array() ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + $fakeCallback = $this->getMock( 'RepoGroupTestHelper' ); + $fakeCallback->expects( $this->never() )->method( 'callback' ); + RepoGroup::singleton()->forEachForeignRepo( + array( $fakeCallback, 'callback' ), array( array() ) ); + } + + private function setUpForeignRepo() { + global $wgUploadDirectory; + $this->setMwGlobals( 'wgForeignFileRepos', array( array( + 'class' => 'ForeignAPIRepo', + 'name' => 'wikimediacommons', + 'backend' => 'wikimediacommons-backend', + 'apibase' => 'https://commons.wikimedia.org/w/api.php', + 'hashLevels' => 2, + 'fetchDescription' => true, + 'descriptionCacheExpiry' => 43200, + 'apiThumbCacheExpiry' => 86400, + 'directory' => $wgUploadDirectory + ) ) ); + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + } +} + +/** + * Quick helper class to use as a mock callback for RepoGroup::singleton()->forEachForeignRepo. + */ +class RepoGroupTestHelper { + function callback( FileRepo $repo, array $foo ) { + return true; + } +} diff --git a/tests/phpunit/includes/filerepo/StoreBatchTest.php b/tests/phpunit/includes/filerepo/StoreBatchTest.php new file mode 100644 index 00000000..9cc2efbf --- /dev/null +++ b/tests/phpunit/includes/filerepo/StoreBatchTest.php @@ -0,0 +1,146 @@ +<?php + +/** + * @group FileRepo + * @group medium + */ +class StoreBatchTest extends MediaWikiTestCase { + + protected $createdFiles; + protected $date; + /** @var FileRepo */ + protected $repo; + + protected function setUp() { + global $wgFileBackends; + parent::setUp(); + + # Forge a FSRepo object to not have to rely on local wiki settings + $tmpPrefix = wfTempDir() . '/storebatch-test-' . time() . '-' . mt_rand(); + if ( $this->getCliArg( 'use-filebackend' ) ) { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + } + } + $useConfig['lockManager'] = LockManagerGroup::singleton()->get( $useConfig['lockManager'] ); + unset( $useConfig['fileJournal'] ); + $useConfig['name'] = 'local-testing'; // swap name + $class = $useConfig['class']; + $backend = new $class( $useConfig ); + } else { + $backend = new FSFileBackend( array( + 'name' => 'local-testing', + 'wikiId' => wfWikiID(), + 'containerPaths' => array( + 'unittests-public' => "{$tmpPrefix}-public", + 'unittests-thumb' => "{$tmpPrefix}-thumb", + 'unittests-temp' => "{$tmpPrefix}-temp", + 'unittests-deleted' => "{$tmpPrefix}-deleted", + ) + ) ); + } + $this->repo = new FileRepo( array( + 'name' => 'unittests', + 'backend' => $backend + ) ); + + $this->date = gmdate( "YmdHis" ); + $this->createdFiles = array(); + } + + protected function tearDown() { + $this->repo->cleanupBatch( $this->createdFiles ); // delete files + foreach ( $this->createdFiles as $tmp ) { // delete dirs + $tmp = $this->repo->resolveVirtualUrl( $tmp ); + while ( $tmp = FileBackend::parentStoragePath( $tmp ) ) { + $this->repo->getBackend()->clean( array( 'dir' => $tmp ) ); + } + } + parent::tearDown(); + } + + /** + * Store a file or virtual URL source into a media file name. + * + * @param string $originalName The title of the image + * @param string $srcPath The filepath or virtual URL + * @param int $flags Flags to pass into repo::store(). + * @return FileRepoStatus + */ + private function storeit( $originalName, $srcPath, $flags ) { + $hashPath = $this->repo->getHashPath( $originalName ); + $dstRel = "$hashPath{$this->date}!$originalName"; + $dstUrlRel = $hashPath . $this->date . '!' . rawurlencode( $originalName ); + + $result = $this->repo->store( $srcPath, 'temp', $dstRel, $flags ); + $result->value = $this->repo->getVirtualUrl( 'temp' ) . '/' . $dstUrlRel; + $this->createdFiles[] = $result->value; + + return $result; + } + + /** + * Test storing a file using different flags. + * + * @param string $fn The title of the image + * @param string $infn The name of the file (in the filesystem) + * @param string $otherfn The name of the different file (in the filesystem) + * @param bool $fromrepo 'true' if we want to copy from a virtual URL out of the Repo. + */ + private function storecohort( $fn, $infn, $otherfn, $fromrepo ) { + $f = $this->storeit( $fn, $infn, 0 ); + $this->assertTrue( $f->isOK(), 'failed to store a new file' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + if ( $fromrepo ) { + $f = $this->storeit( "Other-$fn", $infn, FileRepo::OVERWRITE ); + $infn = $f->value; + } + // This should work because we're allowed to overwrite + $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE ); + $this->assertTrue( $f->isOK(), 'We should be allowed to overwrite' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should fail because we're overwriting. + $f = $this->storeit( $fn, $infn, 0 ); + $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite' ); + $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should succeed because we're overwriting the same content. + $f = $this->storeit( $fn, $infn, FileRepo::OVERWRITE_SAME ); + $this->assertTrue( $f->isOK(), 'We should be able to overwrite the same content' ); + $this->assertEquals( $f->failCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + // This should fail because we're overwriting different content. + if ( $fromrepo ) { + $f = $this->storeit( "Other-$fn", $otherfn, FileRepo::OVERWRITE ); + $otherfn = $f->value; + } + $f = $this->storeit( $fn, $otherfn, FileRepo::OVERWRITE_SAME ); + $this->assertFalse( $f->isOK(), 'We should not be allowed to overwrite different content' ); + $this->assertEquals( $f->failCount, 1, "counts wrong {$f->successCount} {$f->failCount}" ); + $this->assertEquals( $f->successCount, 0, "counts wrong {$f->successCount} {$f->failCount}" ); + } + + /** + * @covers FileRepo::store + */ + public function teststore() { + global $IP; + $this->storecohort( + "Test1.png", + "$IP/tests/phpunit/data/filerepo/wiki.png", + "$IP/tests/phpunit/data/filerepo/video.png", + false + ); + $this->storecohort( + "Test2.png", + "$IP/tests/phpunit/data/filerepo/wiki.png", + "$IP/tests/phpunit/data/filerepo/video.png", + true + ); + } +} diff --git a/tests/phpunit/includes/filerepo/file/FileTest.php b/tests/phpunit/includes/filerepo/file/FileTest.php new file mode 100644 index 00000000..8e8b8a9e --- /dev/null +++ b/tests/phpunit/includes/filerepo/file/FileTest.php @@ -0,0 +1,386 @@ +<?php + +class FileTest extends MediaWikiMediaTestCase { + + /** + * @param string $filename + * @param bool $expected + * @dataProvider providerCanAnimate + */ + function testCanAnimateThumbIfAppropriate( $filename, $expected ) { + $this->setMwGlobals( 'wgMaxAnimatedGifArea', 9000 ); + $file = $this->dataFile( $filename ); + $this->assertEquals( $file->canAnimateThumbIfAppropriate(), $expected ); + } + + function providerCanAnimate() { + return array( + array( 'nonanimated.gif', true ), + array( 'jpeg-comment-utf.jpg', true ), + array( 'test.tiff', true ), + array( 'Animated_PNG_example_bouncing_beach_ball.png', false ), + array( 'greyscale-png.png', true ), + array( 'Toll_Texas_1.svg', true ), + array( 'LoremIpsum.djvu', true ), + array( '80x60-2layers.xcf', true ), + array( 'Soccer_ball_animated.svg', false ), + array( 'Bishzilla_blink.gif', false ), + array( 'animated.gif', true ), + ); + } + + /** + * @dataProvider getThumbnailBucketProvider + * @covers File::getThumbnailBucket + */ + public function testGetThumbnailBucket( $data ) { + $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] ); + $this->setMwGlobals( 'wgThumbnailMinimumBucketDistance', $data['minimumBucketDistance'] ); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', false ) ) + ->setMethods( array( 'getWidth' ) ) + ->getMockForAbstractClass(); + + $fileMock->expects( $this->any() ) + ->method( 'getWidth' ) + ->will( $this->returnValue( $data['width'] ) ); + + $this->assertEquals( + $data['expectedBucket'], + $fileMock->getThumbnailBucket( $data['requestedWidth'] ), + $data['message'] ); + } + + public function getThumbnailBucketProvider() { + $defaultBuckets = array( 256, 512, 1024, 2048, 4096 ); + + return array( + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 120, + 'expectedBucket' => 256, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 300, + 'expectedBucket' => 512, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => 2048, + 'message' => 'Picking bucket bigger than requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 2048, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because none is bigger than the requested size' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 3500, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because requested size is bigger than original' + ) ), + array( array( + 'buckets' => array( 1024 ), + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because requested size equals biggest bucket' + ) ), + array( array( + 'buckets' => null, + 'minimumBucketDistance' => 0, + 'width' => 3000, + 'requestedWidth' => 1024, + 'expectedBucket' => false, + 'message' => 'Picking no bucket because no buckets have been specified' + ) ), + array( array( + 'buckets' => array( 256, 512 ), + 'minimumBucketDistance' => 10, + 'width' => 3000, + 'requestedWidth' => 245, + 'expectedBucket' => 256, + 'message' => 'Requested width is distant enough from next bucket for it to be picked' + ) ), + array( array( + 'buckets' => array( 256, 512 ), + 'minimumBucketDistance' => 10, + 'width' => 3000, + 'requestedWidth' => 246, + 'expectedBucket' => 512, + 'message' => 'Requested width is too close to next bucket, picking next one' + ) ), + ); + } + + /** + * @dataProvider getThumbnailSourceProvider + * @covers File::getThumbnailSource + */ + public function testGetThumbnailSource( $data ) { + $backendMock = $this->getMockBuilder( 'FSFileBackend' ) + ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) ) + ->getMock(); + + $repoMock = $this->getMockBuilder( 'FileRepo' ) + ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) ) + ->setMethods( array( 'fileExists', 'getLocalReference' ) ) + ->getMock(); + + $fsFile = new FSFile( 'fsFilePath' ); + + $repoMock->expects( $this->any() ) + ->method( 'fileExists' ) + ->will( $this->returnValue( true ) ); + + $repoMock->expects( $this->any() ) + ->method( 'getLocalReference' ) + ->will( $this->returnValue( $fsFile ) ); + + $handlerMock = $this->getMock( 'BitmapHandler', array( 'supportsBucketing' ) ); + $handlerMock->expects( $this->any() ) + ->method( 'supportsBucketing' ) + ->will( $this->returnValue( $data['supportsBucketing'] ) ); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', $repoMock ) ) + ->setMethods( array( 'getThumbnailBucket', 'getLocalRefPath', 'getHandler' ) ) + ->getMockForAbstractClass(); + + $fileMock->expects( $this->any() ) + ->method( 'getThumbnailBucket' ) + ->will( $this->returnValue( $data['thumbnailBucket'] ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getLocalRefPath' ) + ->will( $this->returnValue( 'localRefPath' ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getHandler' ) + ->will( $this->returnValue( $handlerMock ) ); + + $reflection = new ReflectionClass( $fileMock ); + $reflection_property = $reflection->getProperty( 'handler' ); + $reflection_property->setAccessible( true ); + $reflection_property->setValue( $fileMock, $handlerMock ); + + if ( !is_null( $data['tmpBucketedThumbCache'] ) ) { + $reflection_property = $reflection->getProperty( 'tmpBucketedThumbCache' ); + $reflection_property->setAccessible( true ); + $reflection_property->setValue( $fileMock, $data['tmpBucketedThumbCache'] ); + } + + $result = $fileMock->getThumbnailSource( + array( 'physicalWidth' => $data['physicalWidth'] ) ); + + $this->assertEquals( $data['expectedPath'], $result['path'], $data['message'] ); + } + + public function getThumbnailSourceProvider() { + return array( + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'fsFilePath', + 'message' => 'Path downloaded from storage' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => array( 1024 => '/tmp/shouldnotexist' + rand() ), + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'fsFilePath', + 'message' => 'Path downloaded from storage because temp file is missing' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => array( 1024 => '/tmp' ), + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => '/tmp', + 'message' => 'Temporary path because temp file was found' + ) ), + array( array( + 'supportsBucketing' => false, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => 1024, + 'physicalWidth' => 2048, + 'expectedPath' => 'localRefPath', + 'message' => 'Original file path because bucketing is unsupported by handler' + ) ), + array( array( + 'supportsBucketing' => true, + 'tmpBucketedThumbCache' => null, + 'thumbnailBucket' => false, + 'physicalWidth' => 2048, + 'expectedPath' => 'localRefPath', + 'message' => 'Original file path because no width provided' + ) ), + ); + } + + /** + * @dataProvider generateBucketsIfNeededProvider + * @covers File::generateBucketsIfNeeded + */ + public function testGenerateBucketsIfNeeded( $data ) { + $this->setMwGlobals( 'wgThumbnailBuckets', $data['buckets'] ); + + $backendMock = $this->getMockBuilder( 'FSFileBackend' ) + ->setConstructorArgs( array( array( 'name' => 'backendMock', 'wikiId' => wfWikiId() ) ) ) + ->getMock(); + + $repoMock = $this->getMockBuilder( 'FileRepo' ) + ->setConstructorArgs( array( array( 'name' => 'repoMock', 'backend' => $backendMock ) ) ) + ->setMethods( array( 'fileExists', 'getLocalReference' ) ) + ->getMock(); + + $fileMock = $this->getMockBuilder( 'File' ) + ->setConstructorArgs( array( 'fileMock', $repoMock ) ) + ->setMethods( array( 'getWidth', 'getBucketThumbPath', 'makeTransformTmpFile', + 'generateAndSaveThumb', 'getHandler' ) ) + ->getMockForAbstractClass(); + + $handlerMock = $this->getMock( 'JpegHandler', array( 'supportsBucketing' ) ); + $handlerMock->expects( $this->any() ) + ->method( 'supportsBucketing' ) + ->will( $this->returnValue( true ) ); + + $fileMock->expects( $this->any() ) + ->method( 'getHandler' ) + ->will( $this->returnValue( $handlerMock ) ); + + $reflectionMethod = new ReflectionMethod( 'File', 'generateBucketsIfNeeded' ); + $reflectionMethod->setAccessible( true ); + + $fileMock->expects( $this->any() ) + ->method( 'getWidth' ) + ->will( $this->returnValue( $data['width'] ) ); + + $fileMock->expects( $data['expectedGetBucketThumbPathCalls'] ) + ->method( 'getBucketThumbPath' ); + + $repoMock->expects( $data['expectedFileExistsCalls'] ) + ->method( 'fileExists' ) + ->will( $this->returnValue( $data['fileExistsReturn'] ) ); + + $fileMock->expects( $data['expectedMakeTransformTmpFile'] ) + ->method( 'makeTransformTmpFile' ) + ->will( $this->returnValue( $data['makeTransformTmpFileReturn'] ) ); + + $fileMock->expects( $data['expectedGenerateAndSaveThumb'] ) + ->method( 'generateAndSaveThumb' ) + ->will( $this->returnValue( $data['generateAndSaveThumbReturn'] ) ); + + $this->assertEquals( $data['expectedResult'], + $reflectionMethod->invoke( + $fileMock, + array( + 'physicalWidth' => $data['physicalWidth'], + 'physicalHeight' => $data['physicalHeight'] ) + ), + $data['message'] ); + } + + public function generateBucketsIfNeededProvider() { + $defaultBuckets = array( 256, 512, 1024, 2048, 4096 ); + + return array( + array( array( + 'buckets' => $defaultBuckets, + 'width' => 256, + 'physicalWidth' => 256, + 'physicalHeight' => 100, + 'expectedGetBucketThumbPathCalls' => $this->never(), + 'expectedFileExistsCalls' => $this->never(), + 'fileExistsReturn' => null, + 'expectedMakeTransformTmpFile' => $this->never(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'No bucket found, nothing to generate' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => true, + 'expectedMakeTransformTmpFile' => $this->never(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'File already exists, no reason to generate buckets' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => false, + 'expectedGenerateAndSaveThumb' => $this->never(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'Cannot generate temp file for bucket' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ), + 'expectedGenerateAndSaveThumb' => $this->once(), + 'generateAndSaveThumbReturn' => false, + 'expectedResult' => false, + 'message' => 'Bucket image could not be generated' + ) ), + array( array( + 'buckets' => $defaultBuckets, + 'width' => 5000, + 'physicalWidth' => 300, + 'physicalHeight' => 200, + 'expectedGetBucketThumbPathCalls' => $this->once(), + 'expectedFileExistsCalls' => $this->once(), + 'fileExistsReturn' => false, + 'expectedMakeTransformTmpFile' => $this->once(), + 'makeTransformTmpFileReturn' => new TempFSFile( '/tmp/foo' ), + 'expectedGenerateAndSaveThumb' => $this->once(), + 'generateAndSaveThumbReturn' => new ThumbnailImage( false, 'bar', false, false ), + 'expectedResult' => true, + 'message' => 'Bucket image could not be generated' + ) ), + ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php new file mode 100644 index 00000000..2c7f50c9 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php @@ -0,0 +1,68 @@ +<?php +/** + * Unit tests for HTMLAutoCompleteSelectField + * + * @covers HTMLAutoCompleteSelectField + */ +class HtmlAutoCompleteSelectFieldTest extends MediaWikiTestCase { + + var $options = array( + 'Bulgaria' => 'BGR', + 'Burkina Faso' => 'BFA', + 'Burundi' => 'BDI', + ); + + /** + * Verify that attempting to instantiate an HTMLAutoCompleteSelectField + * without providing any autocomplete options causes an exception to be + * thrown. + * + * @expectedException MWException + * @expectedExceptionMessage called without any autocompletions + */ + function testMissingAutocompletions() { + new HTMLAutoCompleteSelectField( array( 'fieldname' => 'Test' ) ); + } + + /** + * Verify that the autocomplete options are correctly encoded as + * the 'data-autocomplete' attribute of the field. + * + * @covers HTMLAutoCompleteSelectField::getAttributes + */ + function testGetAttributes() { + $field = new HTMLAutoCompleteSelectField( array( + 'fieldname' => 'Test', + 'autocomplete' => $this->options, + ) ); + + $attributes = $field->getAttributes( array() ); + $this->assertEquals( array_keys( $this->options ), + FormatJson::decode( $attributes['data-autocomplete'] ), + "The 'data-autocomplete' attribute encodes autocomplete option keys as a JSON array." + ); + } + + /** + * Test that the optional select dropdown is included or excluded based on + * the presence or absence of the 'options' parameter. + */ + function testOptionalSelectElement() { + $params = array( + 'fieldname' => 'Test', + 'autocomplete' => $this->options, + 'options' => $this->options, + ); + + $field = new HTMLAutoCompleteSelectField( $params ); + $html = $field->getInputHTML( false ); + $this->assertRegExp( '/select/', $html, + "When the 'options' parameter is set, the HTML includes a <select>" ); + + unset( $params['options'] ); + $field = new HTMLAutoCompleteSelectField( $params ); + $html = $field->getInputHTML( false ); + $this->assertNotRegExp( '/select/', $html, + "When the 'options' parameter is not set, the HTML does not include a <select>" ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php new file mode 100644 index 00000000..5a822f53 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php @@ -0,0 +1,105 @@ +<?php + +/** + * Unit tests for the HTMLCheckMatrix + * @covers HTMLCheckMatrix + */ +class HtmlCheckMatrixTest extends MediaWikiTestCase { + static private $defaultOptions = array( + 'rows' => array( 'r1', 'r2' ), + 'columns' => array( 'c1', 'c2' ), + 'fieldname' => 'test', + ); + + public function testPlainInstantiation() { + try { + new HTMLCheckMatrix( array() ); + } catch ( MWException $e ) { + $this->assertInstanceOf( 'HTMLFormFieldRequiredOptionsException', $e ); + return; + } + + $this->fail( 'Expected MWException indicating missing parameters but none was thrown.' ); + } + + public function testInstantiationWithMinimumRequiredParameters() { + new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( true ); // form instantiation must throw exception on failure + } + + public function testValidateCallsUserDefinedValidationCallback() { + $called = false; + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'validation-callback' => function () use ( &$called ) { + $called = true; + + return false; + }, + ) ); + $this->assertEquals( false, $this->validate( $field, array() ) ); + $this->assertTrue( $called ); + } + + public function testValidateRequiresArrayInput() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertEquals( false, $this->validate( $field, null ) ); + $this->assertEquals( false, $this->validate( $field, true ) ); + $this->assertEquals( false, $this->validate( $field, 'abc' ) ); + $this->assertEquals( false, $this->validate( $field, new stdClass ) ); + $this->assertEquals( true, $this->validate( $field, array() ) ); + } + + public function testValidateAllowsOnlyKnownTags() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertInternalType( 'string', $this->validate( $field, array( 'foo' ) ) ); + } + + public function testValidateAcceptsPartialTagList() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( $this->validate( $field, array() ) ); + $this->assertTrue( $this->validate( $field, array( 'c1-r1' ) ) ); + $this->assertTrue( $this->validate( $field, array( 'c1-r1', 'c1-r2', 'c2-r1', 'c2-r2' ) ) ); + } + + /** + * This form object actually has no visibility into what happens later on, but essentially + * if the data submitted by the user passes validate the following is run: + * foreach ( $field->filterDataForSubmit( $data ) as $k => $v ) { + * $user->setOption( $k, $v ); + * } + */ + public function testValuesForcedOnRemainOn() { + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'force-options-on' => array( 'c2-r1' ), + ) ); + $expected = array( + 'c1-r1' => false, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ); + $this->assertEquals( $expected, $field->filterDataForSubmit( array() ) ); + } + + public function testValuesForcedOffRemainOff() { + $field = new HTMLCheckMatrix( self::$defaultOptions + array( + 'force-options-off' => array( 'c1-r2', 'c2-r2' ), + ) ); + $expected = array( + 'c1-r1' => true, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ); + // array_keys on the result simulates submitting all fields checked + $this->assertEquals( $expected, $field->filterDataForSubmit( array_keys( $expected ) ) ); + } + + protected function validate( HTMLFormField $field, $submitted ) { + return $field->validate( + $submitted, + array( self::$defaultOptions['fieldname'] => $submitted ) + ); + } + +} diff --git a/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/includes/installer/InstallDocFormatterTest.php new file mode 100644 index 00000000..064d5185 --- /dev/null +++ b/tests/phpunit/includes/installer/InstallDocFormatterTest.php @@ -0,0 +1,72 @@ +<?php +/* + * To change this template, choose Tools | Templates + * and open the template in the editor. + */ + +class InstallDocFormatterTest extends MediaWikiTestCase { + /** + * @covers InstallDocFormatter::format + * @dataProvider provideDocFormattingTests + */ + public function testFormat( $expected, $unformattedText, $message = '' ) { + $this->assertEquals( + $expected, + InstallDocFormatter::format( $unformattedText ), + $message + ); + } + + /** + * Provider for testFormat() + */ + public static function provideDocFormattingTests() { + # Format: (expected string, unformattedText string, optional message) + return array( + # Escape some wikitext + array( 'Install <tag>', 'Install <tag>', 'Escaping <' ), + array( 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ), + array( 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ), + array( 'Install __TOC__', 'Install __TOC__', 'Escaping __' ), + array( 'Install ', "Install \r", 'Removing \r' ), + + # Transform \t{1,2} into :{1,2} + array( ':One indentation', "\tOne indentation", 'Replacing a single \t' ), + array( '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ), + + # Transform 'bug 123' links + array( + '<span class="config-plainlink">[https://bugzilla.wikimedia.org/123 bug 123]</span>', + 'bug 123', 'Testing bug 123 links' ), + array( + '(<span class="config-plainlink">[https://bugzilla.wikimedia.org/987654 bug 987654]</span>)', + '(bug 987654)', 'Testing (bug 987654) links' ), + + # "bug abc" shouldn't work + array( 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ), + array( 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ), + + # Transform '$wgFooBar' links + array( + '<span class="config-plainlink">' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]</span>', + '$wgFooBar', 'Testing basic $wgFooBar' ), + array( + '<span class="config-plainlink">' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]</span>', + '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ), + array( + '<span class="config-plainlink">' + . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]</span>', + '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ), + + # Icky variables that shouldn't link + array( + '$myAwesomeVariable', + '$myAwesomeVariable', + 'Testing $myAwesomeVariable (not starting with $wg)' + ), + array( '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ), + ); + } +} diff --git a/tests/phpunit/includes/installer/OracleInstallerTest.php b/tests/phpunit/includes/installer/OracleInstallerTest.php new file mode 100644 index 00000000..fdcecf9e --- /dev/null +++ b/tests/phpunit/includes/installer/OracleInstallerTest.php @@ -0,0 +1,52 @@ +<?php + +/** + * Tests for OracleInstaller + * + * @group Database + * @group Installer + */ + +class OracleInstallerTest extends MediaWikiTestCase { + + /** + * @dataProvider provideOracleConnectStrings + * @covers OracleInstaller::checkConnectStringFormat + */ + public function testCheckConnectStringFormat( $expected, $connectString, $msg = '' ) { + $validity = $expected ? 'should be valid' : 'should NOT be valid'; + $msg = "'$connectString' ($msg) $validity."; + $this->assertEquals( $expected, + OracleInstaller::checkConnectStringFormat( $connectString ), + $msg + ); + } + + /** + * Provider to test OracleInstaller::checkConnectStringFormat() + */ + function provideOracleConnectStrings() { + // expected result, connectString[, message] + return array( + array( true, 'simple_01', 'Simple TNS name' ), + array( true, 'simple_01.world', 'TNS name with domain' ), + array( true, 'simple_01.domain.net', 'TNS name with domain' ), + array( true, 'host123', 'Host only' ), + array( true, 'host123.domain.net', 'FQDN only' ), + array( true, '//host123.domain.net', 'FQDN URL only' ), + array( true, '123.223.213.132', 'Host IP only' ), + array( true, 'host:1521', 'Host and port' ), + array( true, 'host:1521/service', 'Host, port and service' ), + array( true, 'host:1521/service:shared', 'Host, port, service and shared server type' ), + array( true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ), + array( true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ), + array( + true, + 'host:1521/service:shared/instance1', + 'Host, port, service, server type and instance' + ), + array( true, 'host:1521//instance1', 'Host, port and instance' ), + ); + } + +} diff --git a/tests/phpunit/includes/jobqueue/JobQueueTest.php b/tests/phpunit/includes/jobqueue/JobQueueTest.php new file mode 100644 index 00000000..69e40068 --- /dev/null +++ b/tests/phpunit/includes/jobqueue/JobQueueTest.php @@ -0,0 +1,344 @@ +<?php + +/** + * @group JobQueue + * @group medium + * @group Database + */ +class JobQueueTest extends MediaWikiTestCase { + protected $key; + protected $queueRand, $queueRandTTL, $queueFifo, $queueFifoTTL; + + function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed[] = 'job'; + } + + protected function setUp() { + global $wgJobTypeConf; + parent::setUp(); + + $this->setMwGlobals( 'wgMemc', new HashBagOStuff() ); + + if ( $this->getCliArg( 'use-jobqueue' ) ) { + $name = $this->getCliArg( 'use-jobqueue' ); + if ( !isset( $wgJobTypeConf[$name] ) ) { + throw new MWException( "No \$wgJobTypeConf entry for '$name'." ); + } + $baseConfig = $wgJobTypeConf[$name]; + } else { + $baseConfig = array( 'class' => 'JobQueueDB' ); + } + $baseConfig['type'] = 'null'; + $baseConfig['wiki'] = wfWikiID(); + $variants = array( + 'queueRand' => array( 'order' => 'random', 'claimTTL' => 0 ), + 'queueRandTTL' => array( 'order' => 'random', 'claimTTL' => 10 ), + 'queueTimestamp' => array( 'order' => 'timestamp', 'claimTTL' => 0 ), + 'queueTimestampTTL' => array( 'order' => 'timestamp', 'claimTTL' => 10 ), + 'queueFifo' => array( 'order' => 'fifo', 'claimTTL' => 0 ), + 'queueFifoTTL' => array( 'order' => 'fifo', 'claimTTL' => 10 ), + ); + foreach ( $variants as $q => $settings ) { + try { + $this->$q = JobQueue::factory( $settings + $baseConfig ); + if ( !( $this->$q instanceof JobQueueDB ) ) { + $this->$q->setTestingPrefix( 'unittests-' . wfRandomString( 32 ) ); + } + } catch ( MWException $e ) { + // unsupported? + // @todo What if it was another error? + }; + } + } + + protected function tearDown() { + parent::tearDown(); + foreach ( + array( + 'queueRand', 'queueRandTTL', 'queueTimestamp', 'queueTimestampTTL', + 'queueFifo', 'queueFifoTTL' + ) as $q + ) { + if ( $this->$q ) { + $this->$q->delete(); + } + $this->$q = null; + } + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue::getWiki + */ + public function testGetWiki( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + $this->assertEquals( wfWikiID(), $queue->getWiki(), "Proper wiki ID ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue::getType + */ + public function testGetType( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + $this->assertEquals( 'null', $queue->getType(), "Proper job type ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testBasicOperations( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $this->assertNull( $queue->push( $this->newJob() ), "Push worked ($desc)" ); + $this->assertNull( $queue->batchPush( array( $this->newJob() ) ), "Push worked ($desc)" ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 2, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + $jobs = iterator_to_array( $queue->getAllQueuedJobs() ); + $this->assertEquals( 2, count( $jobs ), "Queue iterator size is correct ($desc)" ); + + $job1 = $queue->pop(); + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $job2 = $queue->pop(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 2, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job1 ); + + $queue->flushCaches(); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job2 ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + + $this->assertNull( $queue->batchPush( array( $this->newJob(), $this->newJob() ) ), + "Push worked ($desc)" ); + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->delete(); + $queue->flushCaches(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testBasicDeduplication( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $this->assertNull( + $queue->batchPush( + array( $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ) + ), + "Push worked ($desc)" ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $this->assertNull( + $queue->batchPush( + array( $this->newDedupedJob(), $this->newDedupedJob(), $this->newDedupedJob() ) + ), + "Push worked ($desc)" + ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 1, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $job1 = $queue->pop(); + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + if ( $recycles ) { + $this->assertEquals( 1, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } else { + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + $queue->ack( $job1 ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Active job count ($desc)" ); + } + + /** + * @dataProvider provider_queueLists + * @covers JobQueue + */ + public function testRootDeduplication( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + $id = wfRandomString( 32 ); + $root1 = Job::newRootJobParams( "nulljobspam:$id" ); // task ID/timestamp + for ( $i = 0; $i < 5; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( 0, $root1 ) ), "Push worked ($desc)" ); + } + $queue->deduplicateRootJob( $this->newJob( 0, $root1 ) ); + sleep( 1 ); // roo job timestamp will increase + $root2 = Job::newRootJobParams( "nulljobspam:$id" ); // task ID/timestamp + $this->assertNotEquals( $root1['rootJobTimestamp'], $root2['rootJobTimestamp'], + "Root job signatures have different timestamps." ); + for ( $i = 0; $i < 5; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( 0, $root2 ) ), "Push worked ($desc)" ); + } + $queue->deduplicateRootJob( $this->newJob( 0, $root2 ) ); + + $this->assertFalse( $queue->isEmpty(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 10, $queue->getSize(), "Queue size is correct ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + + $dupcount = 0; + $jobs = array(); + do { + $job = $queue->pop(); + if ( $job ) { + $jobs[] = $job; + $queue->ack( $job ); + } + if ( $job instanceof DuplicateJob ) { + ++$dupcount; + } + } while ( $job ); + + $this->assertEquals( 10, count( $jobs ), "Correct number of jobs popped ($desc)" ); + $this->assertEquals( 5, $dupcount, "Correct number of duplicate jobs popped ($desc)" ); + } + + /** + * @dataProvider provider_fifoQueueLists + * @covers JobQueue + */ + public function testJobOrder( $queue, $recycles, $desc ) { + $queue = $this->$queue; + if ( !$queue ) { + $this->markTestSkipped( $desc ); + } + + $this->assertTrue( $queue->isEmpty(), "Queue is empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "Queue is empty ($desc)" ); + + for ( $i = 0; $i < 10; ++$i ) { + $this->assertNull( $queue->push( $this->newJob( $i ) ), "Push worked ($desc)" ); + } + + for ( $i = 0; $i < 10; ++$i ) { + $job = $queue->pop(); + $this->assertTrue( $job instanceof Job, "Jobs popped from queue ($desc)" ); + $params = $job->getParams(); + $this->assertEquals( $i, $params['i'], "Job popped from queue is FIFO ($desc)" ); + $queue->ack( $job ); + } + + $this->assertFalse( $queue->pop(), "Queue is not empty ($desc)" ); + + $queue->flushCaches(); + $this->assertEquals( 0, $queue->getSize(), "Queue is empty ($desc)" ); + $this->assertEquals( 0, $queue->getAcquiredCount(), "No jobs active ($desc)" ); + } + + public static function provider_queueLists() { + return array( + array( 'queueRand', false, 'Random queue without ack()' ), + array( 'queueRandTTL', true, 'Random queue with ack()' ), + array( 'queueTimestamp', false, 'Time ordered queue without ack()' ), + array( 'queueTimestampTTL', true, 'Time ordered queue with ack()' ), + array( 'queueFifo', false, 'FIFO ordered queue without ack()' ), + array( 'queueFifoTTL', true, 'FIFO ordered queue with ack()' ) + ); + } + + public static function provider_fifoQueueLists() { + return array( + array( 'queueFifo', false, 'Ordered queue without ack()' ), + array( 'queueFifoTTL', true, 'Ordered queue with ack()' ) + ); + } + + function newJob( $i = 0, $rootJob = array() ) { + return new NullJob( Title::newMainPage(), + array( 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 0, 'i' => $i ) + $rootJob ); + } + + function newDedupedJob( $i = 0, $rootJob = array() ) { + return new NullJob( Title::newMainPage(), + array( 'lives' => 0, 'usleep' => 0, 'removeDuplicates' => 1, 'i' => $i ) + $rootJob ); + } +} diff --git a/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php b/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php new file mode 100644 index 00000000..3e232a93 --- /dev/null +++ b/tests/phpunit/includes/jobqueue/RefreshLinksPartitionTest.php @@ -0,0 +1,112 @@ +<?php + +/** + * @group JobQueue + * @group medium + * @group Database + */ +class RefreshLinksPartitionTest extends MediaWikiTestCase { + public function __construct( $name = null, array $data = array(), $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed[] = 'page'; + $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'pagelinks'; + } + + /** + * @dataProvider provider_backlinks + */ + public function testRefreshLinks( $ns, $dbKey, $pages ) { + $title = Title::makeTitle( $ns, $dbKey ); + + foreach ( $pages as $page ) { + list( $bns, $bdbkey ) = $page; + $bpage = WikiPage::factory( Title::makeTitle( $bns, $bdbkey ) ); + $content = ContentHandler::makeContent( "[[{$title->getPrefixedText()}]]", $bpage->getTitle() ); + $bpage->doEditContent( $content, "test" ); + } + + $title->getBacklinkCache()->clear(); + $this->assertEquals( + 20, + $title->getBacklinkCache()->getNumLinks( 'pagelinks' ), + 'Correct number of backlinks' + ); + + $job = new RefreshLinksJob( $title, array( 'recursive' => true, 'table' => 'pagelinks' ) + + Job::newRootJobParams( "refreshlinks:pagelinks:{$title->getPrefixedText()}" ) ); + $extraParams = $job->getRootJobParams(); + $jobs = BacklinkJobUtils::partitionBacklinkJob( $job, 9, 1, array( 'params' => $extraParams ) ); + + $this->assertEquals( 10, count( $jobs ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[0], current( $jobs[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $pages[8], current( $jobs[8]->params['pages'] ), + 'Last leaf job is leaf job with proper title' ); + $this->assertEquals( true, isset( $jobs[9]->params['recursive'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( true, $jobs[9]->params['recursive'], + 'Last job is recursive sub-job' ); + $this->assertEquals( true, is_array( $jobs[9]->params['range'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( $title->getPrefixedText(), $jobs[0]->getTitle()->getPrefixedText(), + 'Base job title retainend in leaf job' ); + $this->assertEquals( $title->getPrefixedText(), $jobs[9]->getTitle()->getPrefixedText(), + 'Base job title retainend recursive sub-job' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs[9]->params['rootJobSignature'], + 'Recursive sub-job has root params' ); + + $jobs2 = BacklinkJobUtils::partitionBacklinkJob( + $jobs[9], + 9, + 1, + array( 'params' => $extraParams ) + ); + + $this->assertEquals( 10, count( $jobs2 ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[9], current( $jobs2[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $pages[17], current( $jobs2[8]->params['pages'] ), + 'Last leaf job is leaf job with proper title' ); + $this->assertEquals( true, isset( $jobs2[9]->params['recursive'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( true, $jobs2[9]->params['recursive'], + 'Last job is recursive sub-job' ); + $this->assertEquals( true, is_array( $jobs2[9]->params['range'] ), + 'Last job is recursive sub-job' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs2[9]->params['rootJobSignature'], + 'Recursive sub-job has root params' ); + + $jobs3 = BacklinkJobUtils::partitionBacklinkJob( + $jobs2[9], + 9, + 1, + array( 'params' => $extraParams ) + ); + + $this->assertEquals( 2, count( $jobs3 ), 'Correct number of sub-jobs' ); + $this->assertEquals( $pages[18], current( $jobs3[0]->params['pages'] ), + 'First job is leaf job with proper title' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[0]->params['rootJobSignature'], + 'Leaf job has root params' ); + $this->assertEquals( $pages[19], current( $jobs3[1]->params['pages'] ), + 'Last job is leaf job with proper title' ); + $this->assertEquals( $extraParams['rootJobSignature'], $jobs3[1]->params['rootJobSignature'], + 'Last leaf job has root params' ); + } + + public static function provider_backlinks() { + $pages = array(); + for ( $i = 0; $i < 20; ++$i ) { + $pages[] = array( 0, "Page-$i" ); + } + return array( + array( 10, 'Bang', $pages ) + ); + } +} diff --git a/tests/phpunit/includes/json/FormatJsonTest.php b/tests/phpunit/includes/json/FormatJsonTest.php new file mode 100644 index 00000000..af68ab03 --- /dev/null +++ b/tests/phpunit/includes/json/FormatJsonTest.php @@ -0,0 +1,279 @@ +<?php + +/** + * @covers FormatJson + */ +class FormatJsonTest extends MediaWikiTestCase { + + public static function provideEncoderPrettyPrinting() { + return array( + // Four spaces + array( true, ' ' ), + array( ' ', ' ' ), + // Two spaces + array( ' ', ' ' ), + // One tab + array( "\t", "\t" ), + ); + } + + /** + * @dataProvider provideEncoderPrettyPrinting + */ + public function testEncoderPrettyPrinting( $pretty, $expectedIndent ) { + $obj = array( + 'emptyObject' => new stdClass, + 'emptyArray' => array(), + 'string' => 'foobar\\', + 'filledArray' => array( + array( + 123, + 456, + ), + // Nested json works without problems + '"7":["8",{"9":"10"}]', + // Whitespace clean up doesn't touch strings that look alike + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", + ), + ); + + // No trailing whitespace, no trailing linefeed + $json = '{ + "emptyObject": {}, + "emptyArray": [], + "string": "foobar\\\\", + "filledArray": [ + [ + 123, + 456 + ], + "\"7\":[\"8\",{\"9\":\"10\"}]", + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" + ] +}'; + + $json = str_replace( "\r", '', $json ); // Windows compat + $json = str_replace( "\t", $expectedIndent, $json ); + $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); + } + + public static function provideEncodeDefault() { + return self::getEncodeTestCases( array() ); + } + + /** + * @dataProvider provideEncodeDefault + */ + public function testEncodeDefault( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from ) ); + } + + public static function provideEncodeUtf8() { + return self::getEncodeTestCases( array( 'unicode' ) ); + } + + /** + * @dataProvider provideEncodeUtf8 + */ + public function testEncodeUtf8( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); + } + + public static function provideEncodeXmlMeta() { + return self::getEncodeTestCases( array( 'xmlmeta' ) ); + } + + /** + * @dataProvider provideEncodeXmlMeta + */ + public function testEncodeXmlMeta( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); + } + + public static function provideEncodeAllOk() { + return self::getEncodeTestCases( array( 'unicode', 'xmlmeta' ) ); + } + + /** + * @dataProvider provideEncodeAllOk + */ + public function testEncodeAllOk( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); + } + + public function testEncodePhpBug46944() { + $this->assertNotEquals( + '\ud840\udc00', + strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), + 'Test encoding an broken json_encode character (U+20000)' + ); + } + + public function testDecodeReturnType() { + $this->assertInternalType( + 'object', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), + 'Default to object' + ); + + $this->assertInternalType( + 'array', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), + 'Optional array' + ); + } + + public static function provideParse() { + return array( + array( null ), + array( true ), + array( false ), + array( 0 ), + array( 1 ), + array( 1.2 ), + array( '' ), + array( 'str' ), + array( array( 0, 1, 2 ) ), + array( array( 'a' => 'b' ) ), + array( array( 'a' => 'b' ) ), + array( array( 'a' => 'b', 'x' => array( 'c' => 'd' ) ) ), + ); + } + + /** + * Recursively convert arrays into stdClass + * @param array|string|bool|int|float|null $value + * @return stdClass|string|bool|int|float|null + */ + public static function toObject( $value ) { + return !is_array( $value ) ? $value : (object) array_map( __METHOD__, $value ); + } + + /** + * @dataProvider provideParse + * @param mixed $value + */ + public function testParse( $value ) { + $expected = self::toObject( $value ); + $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); + $this->assertJson( $json ); + + $st = FormatJson::parse( $json ); + $this->assertType( 'Status', $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expected, $st->getValue() ); + + $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); + $this->assertType( 'Status', $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $value, $st->getValue() ); + } + + public static function provideParseTryFixing() { + return array( + array( "[,]", '[]' ), + array( "[ , ]", '[]' ), + array( "[ , }", false ), + array( '[1],', false ), + array( "[1,]", '[1]' ), + array( "[1\n,]", '[1]' ), + array( "[1,\n]", '[1]' ), + array( "[1,]\n", '[1]' ), + array( "[1\n,\n]\n", '[1]' ), + array( '["a,",]', '["a,"]' ), + array( "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ), + array( '[[1,],[2,],[3,]]', false ), // I wish we could parse this, but would need quote parsing + array( '[1,,]', false ), + ); + } + + /** + * @dataProvider provideParseTryFixing + * @param string $value + * @param string|bool $expected + */ + public function testParseTryFixing( $value, $expected ) { + $st = FormatJson::parse( $value, FormatJson::TRY_FIXING ); + $this->assertType( 'Status', $st ); + if ( $expected === false ) { + $this->assertFalse( $st->isOK() ); + } else { + $this->assertFalse( $st->isGood() ); + $this->assertTrue( $st->isOK() ); + $val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK ); + $this->assertEquals( $expected, $val ); + } + } + + public static function provideParseErrors() { + return array( + array( 'aaa' ), + array( '{"j": 1 ] }' ), + ); + } + + /** + * @dataProvider provideParseErrors + * @param mixed $value + */ + public function testParseErrors( $value ) { + $st = FormatJson::parse( $value ); + $this->assertType( 'Status', $st ); + $this->assertFalse( $st->isOK() ); + } + + /** + * Generate a set of test cases for a particular combination of encoder options. + * + * @param array $unescapedGroups List of character groups to leave unescaped + * @return array Arrays of unencoded strings and corresponding encoded strings + */ + private static function getEncodeTestCases( array $unescapedGroups ) { + $groups = array( + 'always' => array( + // Forward slash (always unescaped) + '/' => '/', + + // Control characters + "\0" => '\u0000', + "\x08" => '\b', + "\t" => '\t', + "\n" => '\n', + "\r" => '\r', + "\f" => '\f', + "\x1f" => '\u001f', // representative example + + // Double quotes + '"' => '\"', + + // Backslashes + '\\' => '\\\\', + '\\\\' => '\\\\\\\\', + '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping + + // Line terminators + "\xe2\x80\xa8" => '\u2028', + "\xe2\x80\xa9" => '\u2029', + ), + 'unicode' => array( + "\xc3\xa9" => '\u00e9', + "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP + ), + 'xmlmeta' => array( + '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits + '>' => '\u003E', + '&' => '\u0026', + ), + ); + + $cases = array(); + foreach ( $groups as $name => $rules ) { + $leaveUnescaped = in_array( $name, $unescapedGroups ); + foreach ( $rules as $from => $to ) { + $cases[] = array( $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ); + } + } + + return $cases; + } +} diff --git a/tests/phpunit/includes/libs/CSSMinTest.php b/tests/phpunit/includes/libs/CSSMinTest.php new file mode 100644 index 00000000..43c50869 --- /dev/null +++ b/tests/phpunit/includes/libs/CSSMinTest.php @@ -0,0 +1,401 @@ +<?php +/** + * This file test the CSSMin library shipped with Mediawiki. + * + * @author Timo Tijhof + */ + +class CSSMinTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $server = 'http://doc.example.org'; + + $this->setMwGlobals( array( + 'wgServer' => $server, + 'wgCanonicalServer' => $server, + ) ); + } + + /** + * @dataProvider provideMinifyCases + * @covers CSSMin::minify + */ + public function testMinify( $code, $expectedOutput ) { + $minified = CSSMin::minify( $code ); + + $this->assertEquals( + $expectedOutput, + $minified, + 'Minified output should be in the form expected.' + ); + } + + public static function provideMinifyCases() { + return array( + // Whitespace + array( "\r\t\f \v\n\r", "" ), + array( "foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + + // Loose comments + array( "/* foo */", "" ), + array( "/*******\n foo\n *******/", "" ), + array( "/*!\n foo\n */", "" ), + + // Inline comments in various different places + array( "/* comment */foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo/* comment */, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo,/* comment */ bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar/* comment */ {\n\tprop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\t/* comment */prop: value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\tprop: /* comment */value;\n}", "foo,bar{prop:value}" ), + array( "foo, bar {\n\tprop: value /* comment */;\n}", "foo,bar{prop:value }" ), + array( "foo, bar {\n\tprop: value; /* comment */\n}", "foo,bar{prop:value; }" ), + + // Keep track of things that aren't as minified as much as they + // could be (bug 35493) + array( 'foo { prop: value ;}', 'foo{prop:value }' ), + array( 'foo { prop : value; }', 'foo{prop :value}' ), + array( 'foo { prop: value ; }', 'foo{prop:value }' ), + array( 'foo { font-family: "foo" , "bar"; }', 'foo{font-family:"foo" ,"bar"}' ), + array( "foo { src:\n\turl('foo') ,\n\turl('bar') ; }", "foo{src:url('foo') ,url('bar') }" ), + + // Interesting cases with string values + // - Double quotes, single quotes + array( 'foo { content: ""; }', 'foo{content:""}' ), + array( "foo { content: ''; }", "foo{content:''}" ), + array( 'foo { content: "\'"; }', 'foo{content:"\'"}' ), + array( "foo { content: '\"'; }", "foo{content:'\"'}" ), + // - Whitespace in string values + array( 'foo { content: " "; }', 'foo{content:" "}' ), + ); + } + + /** + * This tests funky parameters to CSSMin::remap. testRemapRemapping tests + * the basic functionality. + * + * @dataProvider provideRemapCases + * @covers CSSMin::remap + */ + public function testRemap( $message, $params, $expectedOutput ) { + $remapped = call_user_func_array( 'CSSMin::remap', $params ); + + $messageAdd = " Case: $message"; + $this->assertEquals( + $expectedOutput, + $remapped, + 'CSSMin::remap should return the expected url form.' . $messageAdd + ); + } + + public static function provideRemapCases() { + // Parameter signature: + // CSSMin::remap( $code, $local, $remote, $embedData = true ) + return array( + array( + 'Simple case', + array( 'foo { prop: url(bar.png); }', false, 'http://example.org', false ), + 'foo { prop: url(http://example.org/bar.png); }', + ), + array( + 'Without trailing slash', + array( 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux', false ), + 'foo { prop: url(http://example.org/quux/../bar.png); }', + ), + array( + 'With trailing slash on remote (bug 27052)', + array( 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux/', false ), + 'foo { prop: url(http://example.org/quux/../bar.png); }', + ), + array( + 'Guard against stripping double slashes from query', + array( 'foo { prop: url(bar.png?corge=//grault); }', false, 'http://example.org/quux/', false ), + 'foo { prop: url(http://example.org/quux/bar.png?corge=//grault); }', + ), + array( + 'Expand absolute paths', + array( 'foo { prop: url(/w/skin/images/bar.png); }', false, 'http://example.org/quux', false ), + 'foo { prop: url(http://doc.example.org/w/skin/images/bar.png); }', + ), + ); + } + + /** + * This tests basic functionality of CSSMin::remap. testRemapRemapping tests funky parameters. + * + * @dataProvider provideRemapRemappingCases + * @covers CSSMin::remap + */ + public function testRemapRemapping( $message, $input, $expectedOutput ) { + $localPath = __DIR__ . '/../../data/cssmin/'; + $remotePath = 'http://localhost/w/'; + + $realOutput = CSSMin::remap( $input, $localPath, $remotePath ); + + $this->assertEquals( + $expectedOutput, + preg_replace( '/\d+-\d+-\d+T\d+:\d+:\d+Z/', 'timestamp', $realOutput ), + "CSSMin::remap: $message" + ); + } + + public static function provideRemapRemappingCases() { + // red.gif and green.gif are one-pixel 35-byte GIFs. + // large.png is a 35K PNG that should be non-embeddable. + // Full paths start with http://localhost/w/. + // Timestamps in output are replaced with 'timestamp'. + + // data: URIs for red.gif and green.gif + $red = 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs='; + $green = 'data:image/gif;base64,R0lGODlhAQABAIAAAACAADAAACwAAAAAAQABAAACAkQBADs='; + + return array( + array( + 'Regular file', + 'foo { background: url(red.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + 'Regular file (missing)', + 'foo { background: url(theColorOfHerHair.gif); }', + 'foo { background: url(http://localhost/w/theColorOfHerHair.gif); }', + ), + array( + 'Remote URL', + 'foo { background: url(http://example.org/w/foo.png); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Protocol-relative remote URL', + 'foo { background: url(//example.org/w/foo.png); }', + 'foo { background: url(//example.org/w/foo.png); }', + ), + array( + 'Remote URL with query', + 'foo { background: url(http://example.org/w/foo.png?query=yes); }', + 'foo { background: url(http://example.org/w/foo.png?query=yes); }', + ), + array( + 'Protocol-relative remote URL with query', + 'foo { background: url(//example.org/w/foo.png?query=yes); }', + 'foo { background: url(//example.org/w/foo.png?query=yes); }', + ), + array( + 'Domain-relative URL', + 'foo { background: url(/static/foo.png); }', + 'foo { background: url(http://doc.example.org/static/foo.png); }', + ), + array( + 'Domain-relative URL with query', + 'foo { background: url(/static/foo.png?query=yes); }', + 'foo { background: url(http://doc.example.org/static/foo.png?query=yes); }', + ), + array( + 'Remote URL (unnecessary quotes not preserved)', + 'foo { background: url("http://example.org/w/foo.png"); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Embedded file', + 'foo { /* @embed */ background: url(red.gif); }', + "foo { background: url($red); background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Embedded file, other comments before the rule', + "foo { /* Bar. */ /* @embed */ background: url(red.gif); }", + "foo { /* Bar. */ background: url($red); /* Bar. */ background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Can not re-embed data: URIs', + "foo { /* @embed */ background: url($red); }", + "foo { background: url($red); }", + ), + array( + 'Can not remap data: URIs', + "foo { background: url($red); }", + "foo { background: url($red); }", + ), + array( + 'Can not embed remote URLs', + 'foo { /* @embed */ background: url(http://example.org/w/foo.png); }', + 'foo { background: url(http://example.org/w/foo.png); }', + ), + array( + 'Embedded file (inline @embed)', + 'foo { background: /* @embed */ url(red.gif); }', + "foo { background: url($red); " + . "background: url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Can not embed large files', + 'foo { /* @embed */ background: url(large.png); }', + "foo { background: url(http://localhost/w/large.png?timestamp); }", + ), + array( + 'Two regular files in one rule', + 'foo { background: url(red.gif), url(green.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp), ' + . 'url(http://localhost/w/green.gif?timestamp); }', + ), + array( + 'Two embedded files in one rule', + 'foo { /* @embed */ background: url(red.gif), url(green.gif); }', + "foo { background: url($red), url($green); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/green.gif?timestamp)!ie; }", + ), + array( + 'Two embedded files in one rule (inline @embed)', + 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(green.gif); }', + "foo { background: url($red), url($green); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/green.gif?timestamp)!ie; }", + ), + array( + 'Two embedded files in one rule (inline @embed), one too large', + 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(large.png); }', + "foo { background: url($red), url(http://localhost/w/large.png?timestamp); " + . "background: url(http://localhost/w/red.gif?timestamp), " + . "url(http://localhost/w/large.png?timestamp)!ie; }", + ), + array( + 'Practical example with some noise', + 'foo { /* @embed */ background: #f9f9f9 url(red.gif) 0 0 no-repeat; }', + "foo { background: #f9f9f9 url($red) 0 0 no-repeat; " + . "background: #f9f9f9 url(http://localhost/w/red.gif?timestamp) 0 0 no-repeat!ie; }", + ), + array( + 'Does not mess with other properties', + 'foo { color: red; background: url(red.gif); font-size: small; }', + 'foo { color: red; background: url(http://localhost/w/red.gif?timestamp); font-size: small; }', + ), + array( + 'Spacing and miscellanea not changed (1)', + 'foo { background: url(red.gif); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + 'Spacing and miscellanea not changed (2)', + 'foo {background:url(red.gif)}', + 'foo {background:url(http://localhost/w/red.gif?timestamp)}', + ), + array( + 'Spaces within url() parentheses are ignored', + 'foo { background: url( red.gif ); }', + 'foo { background: url(http://localhost/w/red.gif?timestamp); }', + ), + array( + '@import rule to local file (should we remap this?)', + '@import url(/styles.css)', + '@import url(http://doc.example.org/styles.css)', + ), + array( + '@import rule to URL (should we remap this?)', + '@import url(//localhost/styles.css?query=yes)', + '@import url(//localhost/styles.css?query=yes)', + ), + array( + 'Simple case with comments before url', + 'foo { prop: /* some {funny;} comment */ url(bar.png); }', + 'foo { prop: /* some {funny;} comment */ url(http://localhost/w/bar.png); }', + ), + array( + 'Simple case with comments after url', + 'foo { prop: url(red.gif)/* some {funny;} comment */ ; }', + 'foo { prop: url(http://localhost/w/red.gif?timestamp)/* some {funny;} comment */ ; }', + ), + array( + 'Embedded file with comment before url', + 'foo { /* @embed */ background: /* some {funny;} comment */ url(red.gif); }', + "foo { background: /* some {funny;} comment */ url($red); background: /* some {funny;} comment */ url(http://localhost/w/red.gif?timestamp)!ie; }", + ), + array( + 'Embedded file with comments inside and outside the rule', + 'foo { /* @embed */ background: url(red.gif) /* some {foo;} comment */; /* some {bar;} comment */ }', + "foo { background: url($red) /* some {foo;} comment */; background: url(http://localhost/w/red.gif?timestamp) /* some {foo;} comment */!ie; /* some {bar;} comment */ }", + ), + array( + 'Embedded file with comment outside the rule', + 'foo { /* @embed */ background: url(red.gif); /* some {funny;} comment */ }', + "foo { background: url($red); background: url(http://localhost/w/red.gif?timestamp)!ie; /* some {funny;} comment */ }", + ), + array( + 'Rule with two urls, each with comments', + '{ background: /*asd*/ url(something.png); background: /*jkl*/ url(something.png); }', + '{ background: /*asd*/ url(http://localhost/w/something.png); background: /*jkl*/ url(http://localhost/w/something.png); }', + ), + array( + 'Sanity check for offending line from jquery.ui.theme.css (bug 60077)', + '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }', + '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(http://localhost/w/images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }', + ), + ); + } + + /** + * This tests basic functionality of CSSMin::buildUrlValue. + * + * @dataProvider provideBuildUrlValueCases + * @covers CSSMin::buildUrlValue + */ + public function testBuildUrlValue( $message, $input, $expectedOutput ) { + $this->assertEquals( + $expectedOutput, + CSSMin::buildUrlValue( $input ), + "CSSMin::buildUrlValue: $message" + ); + } + + public static function provideBuildUrlValueCases() { + return array( + array( + 'Full URL', + 'scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s', + 'url(scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s)', + ), + array( + 'data: URI', + 'data:image/png;base64,R0lGODlh/+==', + 'url(data:image/png;base64,R0lGODlh/+==)', + ), + array( + 'URL with quotes', + "https://en.wikipedia.org/wiki/Wendy's", + "url(\"https://en.wikipedia.org/wiki/Wendy's\")", + ), + array( + 'URL with parentheses', + 'https://en.wikipedia.org/wiki/Boston_(band)', + 'url("https://en.wikipedia.org/wiki/Boston_(band)")', + ), + ); + } + + /** + * Seperated because they are currently broken (bug 35492) + * + * @group Broken + * @dataProvider provideStringCases + * @covers CSSMin::remap + */ + public function testMinifyWithCSSStringValues( $code, $expectedOutput ) { + $this->testMinifyOutput( $code, $expectedOutput ); + } + + public static function provideStringCases() { + return array( + // String values should be respected + // - More than one space in a string value + array( 'foo { content: " "; }', 'foo{content:" "}' ), + // - Using a tab in a string value (turns into a space) + array( "foo { content: '\t'; }", "foo{content:'\t'}" ), + // - Using css-like syntax in string values + array( + 'foo::after { content: "{;}"; position: absolute; }', + 'foo::after{content:"{;}";position:absolute}' + ), + ); + } +} diff --git a/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/tests/phpunit/includes/libs/GenericArrayObjectTest.php new file mode 100644 index 00000000..4911f73a --- /dev/null +++ b/tests/phpunit/includes/libs/GenericArrayObjectTest.php @@ -0,0 +1,280 @@ +<?php + +/** + * Tests for the GenericArrayObject and deriving classes. + * + * 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 + * @since 1.20 + * + * @ingroup Test + * @group GenericArrayObject + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +abstract class GenericArrayObjectTest extends MediaWikiTestCase { + + /** + * Returns objects that can serve as elements in the concrete + * GenericArrayObject deriving class being tested. + * + * @since 1.20 + * + * @return array + */ + abstract public function elementInstancesProvider(); + + /** + * Returns the name of the concrete class being tested. + * + * @since 1.20 + * + * @return string + */ + abstract public function getInstanceClass(); + + /** + * Provides instances of the concrete class being tested. + * + * @since 1.20 + * + * @return array + */ + public function instanceProvider() { + $instances = array(); + + foreach ( $this->elementInstancesProvider() as $elementInstances ) { + $instances[] = $this->getNew( $elementInstances[0] ); + } + + return $this->arrayWrap( $instances ); + } + + /** + * @since 1.20 + * + * @param array $elements + * + * @return GenericArrayObject + */ + protected function getNew( array $elements = array() ) { + $class = $this->getInstanceClass(); + + return new $class( $elements ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::__construct + */ + public function testConstructor( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( count( $elements ), $arrayObject->count() ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::isEmpty + */ + public function testIsEmpty( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( $elements === array(), $arrayObject->isEmpty() ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.20 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::offsetUnset + */ + public function testUnset( GenericArrayObject $list ) { + if ( $list->isEmpty() ) { + $this->assertTrue( true ); // We cannot test unset if there are no elements + } else { + $offset = $list->getIterator()->key(); + $count = $list->count(); + $list->offsetUnset( $offset ); + $this->assertEquals( $count - 1, $list->count() ); + } + + if ( !$list->isEmpty() ) { + $offset = $list->getIterator()->key(); + $count = $list->count(); + unset( $list[$offset] ); + $this->assertEquals( $count - 1, $list->count() ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::append + */ + public function testAppend( array $elements ) { + $list = $this->getNew(); + + $listSize = count( $elements ); + + foreach ( $elements as $element ) { + $list->append( $element ); + } + + $this->assertEquals( $listSize, $list->count() ); + + $list = $this->getNew(); + + foreach ( $elements as $element ) { + $list[] = $element; + } + + $this->assertEquals( $listSize, $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->append( $element ); + } ); + } + + /** + * @since 1.20 + * + * @param callable $function + * + * @covers GenericArrayObject::getObjectType + */ + protected function checkTypeChecks( $function ) { + $excption = null; + $list = $this->getNew(); + + $elementClass = $list->getObjectType(); + + foreach ( array( 42, 'foo', array(), new stdClass(), 4.2 ) as $element ) { + $validValid = $element instanceof $elementClass; + + try { + call_user_func( $function, $list, $element ); + $valid = true; + } catch ( InvalidArgumentException $exception ) { + $valid = false; + } + + $this->assertEquals( + $validValid, + $valid, + 'Object of invalid type got successfully added to a GenericArrayObject' + ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::offsetSet + */ + public function testOffsetSet( array $elements ) { + if ( $elements === array() ) { + $this->assertTrue( true ); + + return; + } + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 42, $element ); + $this->assertEquals( $element, $list->offsetGet( 42 ) ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list['oHai'] = $element; + $this->assertEquals( $element, $list['oHai'] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 9001, $element ); + $this->assertEquals( $element, $list[9001] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[0] ); + + $list = $this->getNew(); + $offset = 0; + + foreach ( $elements as $element ) { + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[$offset++] ); + } + + $this->assertEquals( count( $elements ), $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->offsetSet( mt_rand(), $element ); + } ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.21 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::getSerializationData + * @covers GenericArrayObject::serialize + * @covers GenericArrayObject::unserialize + */ + public function testSerialization( GenericArrayObject $list ) { + $serialization = serialize( $list ); + $copy = unserialize( $serialization ); + + $this->assertEquals( $serialization, serialize( $copy ) ); + $this->assertEquals( count( $list ), count( $copy ) ); + + $list = $list->getArrayCopy(); + $copy = $copy->getArrayCopy(); + + $this->assertArrayEquals( $list, $copy, true, true ); + } +} diff --git a/tests/phpunit/includes/libs/HashRingTest.php b/tests/phpunit/includes/libs/HashRingTest.php new file mode 100644 index 00000000..68dfea1f --- /dev/null +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -0,0 +1,56 @@ +<?php + +/** + * @group HashRing + */ +class HashRingTest extends MediaWikiTestCase { + /** + * @covers HashRing + */ + public function testHashRing() { + $ring = new HashRing( array( 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ) ); + + $locations = array(); + for ( $i = 0; $i < 20; $i++ ) { + $locations[ "hello$i"] = $ring->getLocation( "hello$i" ); + } + $expectedLocations = array( + "hello0" => "s5", + "hello1" => "s6", + "hello2" => "s2", + "hello3" => "s5", + "hello4" => "s6", + "hello5" => "s4", + "hello6" => "s5", + "hello7" => "s4", + "hello8" => "s5", + "hello9" => "s5", + "hello10" => "s3", + "hello11" => "s6", + "hello12" => "s1", + "hello13" => "s3", + "hello14" => "s3", + "hello15" => "s5", + "hello16" => "s4", + "hello17" => "s6", + "hello18" => "s6", + "hello19" => "s3" + ); + + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + + $locations = array(); + for ( $i = 0; $i < 5; $i++ ) { + $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 ); + } + + $expectedLocations = array( + "hello0" => array( "s5", "s6" ), + "hello1" => array( "s6", "s4" ), + "hello2" => array( "s2", "s1" ), + "hello3" => array( "s5", "s6" ), + "hello4" => array( "s6", "s4" ), + ); + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + } +} diff --git a/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/tests/phpunit/includes/libs/IEUrlExtensionTest.php new file mode 100644 index 00000000..b7071230 --- /dev/null +++ b/tests/phpunit/includes/libs/IEUrlExtensionTest.php @@ -0,0 +1,173 @@ +<?php + +/** + * Tests for IEUrlExtension::findIE6Extension + * @todo tests below for findIE6Extension should be split into... + * ...a dataprovider and test method. + */ +class IEUrlExtensionTest extends MediaWikiTestCase { + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testSimple() { + $this->assertEquals( + 'y', + IEUrlExtension::findIE6Extension( 'x.y' ), + 'Simple extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testSimpleNoExt() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'x' ), + 'No extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testEmpty() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '' ), + 'Empty string' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testQuestionMark() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '?' ), + 'Question mark only' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExtQuestionMark() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '.x?' ), + 'Extension then question mark' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testQuestionMarkExt() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '?.x' ), + 'Question mark then extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testInvalidChar() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '.x*' ), + 'Extension with invalid character' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testInvalidCharThenExtension() { + $this->assertEquals( + 'x', + IEUrlExtension::findIE6Extension( '*.x' ), + 'Invalid character followed by an extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testMultipleQuestionMarks() { + $this->assertEquals( + 'c', + IEUrlExtension::findIE6Extension( 'a?b?.c?.d?e?f' ), + 'Multiple question marks' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExeException() { + $this->assertEquals( + 'd', + IEUrlExtension::findIE6Extension( 'a?b?.exe?.d?.e' ), + '.exe exception' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testExeException2() { + $this->assertEquals( + 'exe', + IEUrlExtension::findIE6Extension( 'a?b?.exe' ), + '.exe exception 2' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testHash() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'a#b.c' ), + 'Hash character preceding extension' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testHash2() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( 'a?#b.c' ), + 'Hash character preceding extension 2' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testDotAtEnd() { + $this->assertEquals( + '', + IEUrlExtension::findIE6Extension( '.' ), + 'Dot at end of string' + ); + } + + /** + * @covers IEUrlExtension::findIE6Extension + */ + public function testTwoDots() { + $this->assertEquals( + 'z', + IEUrlExtension::findIE6Extension( 'x.y.z' ), + 'Two dots' + ); + } +} diff --git a/tests/phpunit/includes/libs/IPSetTest.php b/tests/phpunit/includes/libs/IPSetTest.php new file mode 100644 index 00000000..d4e5214a --- /dev/null +++ b/tests/phpunit/includes/libs/IPSetTest.php @@ -0,0 +1,252 @@ +<?php + +/** + * @group IPSet + */ +class IPSetTest extends MediaWikiTestCase { + /** + * Provides test cases for IPSetTest::testIPSet + * + * Returns an array of test cases. Each case is an array of (description, + * config, tests). Description is just text output for failure messages, + * config is an array constructor argument for IPSet, and the tests are + * an array of IP => expected (boolean) result against the config dataset. + */ + public static function provideIPSets() { + return array( + array( + 'old_list_subset', + array( + '208.80.152.162', + '10.64.0.123', + '10.64.0.124', + '10.64.0.125', + '10.64.0.126', + '10.64.0.127', + '10.64.0.128', + '10.64.0.129', + '10.64.32.104', + '10.64.32.105', + '10.64.32.106', + '10.64.32.107', + '91.198.174.45', + '91.198.174.46', + '91.198.174.47', + '91.198.174.57', + '2620:0:862:1:A6BA:DBFF:FE30:CFB3', + '91.198.174.58', + '2620:0:862:1:A6BA:DBFF:FE38:FFDA', + '208.80.152.16', + '208.80.152.17', + '208.80.152.18', + '208.80.152.19', + '91.198.174.102', + '91.198.174.103', + '91.198.174.104', + '91.198.174.105', + '91.198.174.106', + '91.198.174.107', + '91.198.174.81', + '2620:0:862:1:26B6:FDFF:FEF5:B2D4', + '91.198.174.82', + '2620:0:862:1:26B6:FDFF:FEF5:ABB4', + '10.20.0.113', + '2620:0:862:102:26B6:FDFF:FEF5:AD9C', + '10.20.0.114', + '2620:0:862:102:26B6:FDFF:FEF5:7C38', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.64.0.122' => false, + '10.64.0.123' => true, + '10.64.0.124' => true, + '10.64.0.129' => true, + '10.64.0.130' => false, + '91.198.174.81' => true, + '91.198.174.80' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:FFFF:FFFF:FFFF:FFFF' => false, + '2001:db8::1234' => false, + '2620:0:862:1:26b6:fdff:fef5:abb3' => false, + '2620:0:862:1:26b6:fdff:fef5:abb4' => true, + '2620:0:862:1:26b6:fdff:fef5:abb5' => false, + ), + ), + array( + 'new_cidr_set', + array( + '208.80.154.0/26', + '2620:0:861:1::/64', + '208.80.154.128/26', + '2620:0:861:2::/64', + '208.80.154.64/26', + '2620:0:861:3::/64', + '208.80.155.96/27', + '2620:0:861:4::/64', + '10.64.0.0/22', + '2620:0:861:101::/64', + '10.64.16.0/22', + '2620:0:861:102::/64', + '10.64.32.0/22', + '2620:0:861:103::/64', + '10.64.48.0/22', + '2620:0:861:107::/64', + '91.198.174.0/25', + '2620:0:862:1::/64', + '10.20.0.0/24', + '2620:0:862:102::/64', + '10.128.0.0/24', + '2620:0:863:101::/64', + '10.2.4.26', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.2.4.25' => false, + '10.2.4.26' => true, + '10.2.4.27' => false, + '10.20.0.255' => true, + '10.128.0.0' => true, + '10.64.17.55' => true, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => false, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => true, + ), + ), + array( + 'empty_set', + array(), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '10.2.4.25' => false, + '10.2.4.26' => false, + '10.2.4.27' => false, + '10.20.0.255' => false, + '10.128.0.0' => false, + '10.64.17.55' => false, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => false, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => false, + ), + ), + array( + 'edge_cases', + array( + '0.0.0.0', + '255.255.255.255', + '::', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', + '10.10.10.10/25', // host bits intentional + ), + array( + '0.0.0.0' => true, + '255.255.255.255' => true, + '10.2.4.25' => false, + '10.2.4.26' => false, + '10.2.4.27' => false, + '10.20.0.255' => false, + '10.128.0.0' => false, + '10.64.17.55' => false, + '10.64.20.0' => false, + '10.64.27.207' => false, + '10.64.31.255' => false, + '0::0' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => true, + '2001:DB8::1' => false, + '2620:0:861:106::45' => false, + '2620:0:862:103::' => false, + '2620:0:862:102:10:20:0:113' => false, + '10.10.9.255' => false, + '10.10.10.0' => true, + '10.10.10.1' => true, + '10.10.10.10' => true, + '10.10.10.126' => true, + '10.10.10.127' => true, + '10.10.10.128' => false, + '10.10.10.177' => false, + '10.10.10.255' => false, + '10.10.11.0' => false, + ), + ), + array( + 'exercise_optimizer', + array( + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffe:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffd:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffc:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffb:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fffa:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:8000/113', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:0/113', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff8:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff7:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff6:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff5:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff4:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff3:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff2:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff1:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:fff0:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffef:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffee:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffec:0/111', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffeb:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffea:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe9:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe8:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe7:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe6:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe5:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe4:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe3:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe2:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe1:0/112', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffe0:0/110', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffc0:0/107', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffa0:0/107', + ), + array( + '0.0.0.0' => false, + '255.255.255.255' => false, + '::' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ff9f:ffff' => false, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffa0:0' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffc0:1234' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffed:ffff' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:fff4:4444' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:fff9:8080' => true, + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => true, + ), + ), + ); + } + + /** + * Validates IPSet loading and matching code + * + * @covers IPSet + * @dataProvider provideIPSets + */ + public function testIPSet( $desc, array $cfg, array $tests ) { + $ipset = new IPSet( $cfg ); + foreach ( $tests as $ip => $expected ) { + $result = $ipset->match( $ip ); + $this->assertEquals( $expected, $result, "Incorrect match() result for $ip in dataset $desc" ); + } + } +} diff --git a/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php new file mode 100644 index 00000000..c8795b2e --- /dev/null +++ b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php @@ -0,0 +1,204 @@ +<?php + +class JavaScriptMinifierTest extends MediaWikiTestCase { + + public static function provideCases() { + return array( + + // Basic whitespace and comments that should be stripped entirely + array( "\r\t\f \v\n\r", "" ), + array( "/* Foo *\n*bar\n*/", "" ), + + /** + * Slashes used inside block comments (bug 26931). + * At some point there was a bug that caused this comment to be ended at '* /', + * causing /M... to be left as the beginning of a regex. + */ + array( + "/**\n * Foo\n * {\n * 'bar' : {\n * " + . "//Multiple rules with configurable operators\n * 'baz' : false\n * }\n */", + "" ), + + /** + * ' Foo \' bar \ + * baz \' quox ' . + */ + array( + "' Foo \\' bar \\\n baz \\' quox ' .length", + "' Foo \\' bar \\\n baz \\' quox '.length" + ), + array( + "\" Foo \\\" bar \\\n baz \\\" quox \" .length", + "\" Foo \\\" bar \\\n baz \\\" quox \".length" + ), + array( "// Foo b/ar baz", "" ), + array( + "/ Foo \\/ bar [ / \\] / ] baz / .length", + "/ Foo \\/ bar [ / \\] / ] baz /.length" + ), + + // HTML comments + array( "<!-- Foo bar", "" ), + array( "<!-- Foo --> bar", "" ), + array( "--> Foo", "" ), + array( "x --> y", "x-->y" ), + + // Semicolon insertion + array( "(function(){return\nx;})", "(function(){return\nx;})" ), + array( "throw\nx;", "throw\nx;" ), + array( "while(p){continue\nx;}", "while(p){continue\nx;}" ), + array( "while(p){break\nx;}", "while(p){break\nx;}" ), + array( "var\nx;", "var x;" ), + array( "x\ny;", "x\ny;" ), + array( "x\n++y;", "x\n++y;" ), + array( "x\n!y;", "x\n!y;" ), + array( "x\n{y}", "x\n{y}" ), + array( "x\n+y;", "x+y;" ), + array( "x\n(y);", "x(y);" ), + array( "5.\nx;", "5.\nx;" ), + array( "0xFF.\nx;", "0xFF.x;" ), + array( "5.3.\nx;", "5.3.x;" ), + + // Semicolon insertion between an expression having an inline + // comment after it, and a statement on the next line (bug 27046). + array( + "var a = this //foo bar \n for ( b = 0; c < d; b++ ) {}", + "var a=this\nfor(b=0;c<d;b++){}" + ), + + // Token separation + array( "x in y", "x in y" ), + array( "/x/g in y", "/x/g in y" ), + array( "x in 30", "x in 30" ), + array( "x + ++ y", "x+ ++y" ), + array( "x ++ + y", "x++ +y" ), + array( "x / /y/.exec(z)", "x/ /y/.exec(z)" ), + + // State machine + array( "/ x/g", "/ x/g" ), + array( "(function(){return/ x/g})", "(function(){return/ x/g})" ), + array( "+/ x/g", "+/ x/g" ), + array( "++/ x/g", "++/ x/g" ), + array( "x/ x/g", "x/x/g" ), + array( "(/ x/g)", "(/ x/g)" ), + array( "if(/ x/g);", "if(/ x/g);" ), + array( "(x/ x/g)", "(x/x/g)" ), + array( "([/ x/g])", "([/ x/g])" ), + array( "+x/ x/g", "+x/x/g" ), + array( "{}/ x/g", "{}/ x/g" ), + array( "+{}/ x/g", "+{}/x/g" ), + array( "(x)/ x/g", "(x)/x/g" ), + array( "if(x)/ x/g", "if(x)/ x/g" ), + array( "for(x;x;{}/ x/g);", "for(x;x;{}/x/g);" ), + array( "x;x;{}/ x/g", "x;x;{}/ x/g" ), + array( "x:{}/ x/g", "x:{}/ x/g" ), + array( "switch(x){case y?z:{}/ x/g:{}/ x/g;}", "switch(x){case y?z:{}/x/g:{}/ x/g;}" ), + array( "function x(){}/ x/g", "function x(){}/ x/g" ), + array( "+function x(){}/ x/g", "+function x(){}/x/g" ), + + // Multiline quoted string + array( "var foo=\"\\\nblah\\\n\";", "var foo=\"\\\nblah\\\n\";" ), + + // Multiline quoted string followed by string with spaces + array( + "var foo=\"\\\nblah\\\n\";\nvar baz = \" foo \";\n", + "var foo=\"\\\nblah\\\n\";var baz=\" foo \";" + ), + + // URL in quoted string ( // is not a comment) + array( + "aNode.setAttribute('href','http://foo.bar.org/baz');", + "aNode.setAttribute('href','http://foo.bar.org/baz');" + ), + + // URL in quoted string after multiline quoted string + array( + "var foo=\"\\\nblah\\\n\";\naNode.setAttribute('href','http://foo.bar.org/baz');", + "var foo=\"\\\nblah\\\n\";aNode.setAttribute('href','http://foo.bar.org/baz');" + ), + + // Division vs. regex nastiness + array( + "alert( (10+10) / '/'.charCodeAt( 0 ) + '//' );", + "alert((10+10)/'/'.charCodeAt(0)+'//');" + ), + array( "if(1)/a /g.exec('Pa ss');", "if(1)/a /g.exec('Pa ss');" ), + + // newline insertion after 1000 chars: break after the "++", not before + array( str_repeat( ';', 996 ) . "if(x++);", str_repeat( ';', 996 ) . "if(x++\n);" ), + + // Unicode letter characters should pass through ok in identifiers (bug 31187) + array( "var KaŝSkatolVal = {}", 'var KaŝSkatolVal={}' ), + + // Per spec unicode char escape values should work in identifiers, + // as long as it's a valid char. In future it might get normalized. + array( "var Ka\\u015dSkatolVal = {}", 'var Ka\\u015dSkatolVal={}' ), + + // Some structures that might look invalid at first sight + array( "var a = 5.;", "var a=5.;" ), + array( "5.0.toString();", "5.0.toString();" ), + array( "5..toString();", "5..toString();" ), + array( "5...toString();", false ), + array( "5.\n.toString();", '5..toString();' ), + ); + } + + /** + * @dataProvider provideCases + * @covers JavaScriptMinifier::minify + */ + public function testJavaScriptMinifierOutput( $code, $expectedOutput ) { + $minified = JavaScriptMinifier::minify( $code ); + + // JSMin+'s parser will throw an exception if output is not valid JS. + // suppression of warnings needed for stupid crap + wfSuppressWarnings(); + $parser = new JSParser(); + wfRestoreWarnings(); + $parser->parse( $minified, 'minify-test.js', 1 ); + + $this->assertEquals( + $expectedOutput, + $minified, + "Minified output should be in the form expected." + ); + } + + public static function provideBug32548() { + return array( + array( + // This one gets interpreted all together by the prior code; + // no break at the 'E' happens. + '1.23456789E55', + ), + array( + // This one breaks under the bad code; splits between 'E' and '+' + '1.23456789E+5', + ), + array( + // This one breaks under the bad code; splits between 'E' and '-' + '1.23456789E-5', + ), + ); + } + + /** + * @dataProvider provideBug32548 + * @covers JavaScriptMinifier::minify + * @todo give this test a real name explaining what is being tested here + */ + public function testBug32548Exponent( $num ) { + // Long line breaking was being incorrectly done between the base and + // exponent part of a number, causing a syntax error. The line should + // instead break at the start of the number. + $prefix = 'var longVarName' . str_repeat( '_', 973 ) . '='; + $suffix = ',shortVarName=0;'; + + $input = $prefix . $num . $suffix; + $expected = $prefix . "\n" . $num . $suffix; + + $minified = JavaScriptMinifier::minify( $input ); + + $this->assertEquals( $expected, $minified, "Line breaks must not occur in middle of exponent" ); + } +} diff --git a/tests/phpunit/includes/libs/MWMessagePackTest.php b/tests/phpunit/includes/libs/MWMessagePackTest.php new file mode 100644 index 00000000..f80f78df --- /dev/null +++ b/tests/phpunit/includes/libs/MWMessagePackTest.php @@ -0,0 +1,75 @@ +<?php +/** + * PHP Unit tests for MWMessagePack + * @covers MWMessagePack + */ +class MWMessagePackTest extends MediaWikiTestCase { + + /** + * Provides test cases for MWMessagePackTest::testMessagePack + * + * Returns an array of test cases. Each case is an array of (type, value, + * expected encoding as hex string). The expected values were generated + * using <https://github.com/msgpack/msgpack-php>, which includes a + * serialization function. + */ + public static function providePacks() { + $tests = array( + array( 'nil', null, 'c0' ), + array( 'bool', true, 'c3' ), + array( 'bool', false, 'c2' ), + array( 'positive fixnum', 0, '00' ), + array( 'positive fixnum', 1, '01' ), + array( 'positive fixnum', 5, '05' ), + array( 'positive fixnum', 35, '23' ), + array( 'uint 8', 128, 'cc80' ), + array( 'uint 16', 1000, 'cd03e8' ), + array( 'uint 32', 100000, 'ce000186a0' ), + array( 'negative fixnum', -1, 'ff' ), + array( 'negative fixnum', -2, 'fe' ), + array( 'int 8', -128, 'd080' ), + array( 'int 8', -35, 'd0dd' ), + array( 'int 16', -1000, 'd1fc18' ), + array( 'int 32', -100000, 'd2fffe7960' ), + array( 'double', 0.1, 'cb3fb999999999999a' ), + array( 'double', 1.1, 'cb3ff199999999999a' ), + array( 'double', 123.456, 'cb405edd2f1a9fbe77' ), + array( 'fix raw', '', 'a0' ), + array( 'fix raw', 'foobar', 'a6666f6f626172' ), + array( + 'raw 16', + 'Lorem ipsum dolor sit amet amet.', + 'da00204c6f72656d20697073756d20646f6c6f722073697420616d657420616d65742e' + ), + array( + 'fix array', + array( 'abc', 'def', 'ghi' ), + '93a3616263a3646566a3676869' + ), + array( + 'fix map', + array( 'one' => 1, 'two' => 2 ), + '82a36f6e6501a374776f02' + ), + ); + + if ( PHP_INT_SIZE > 4 ) { + $tests[] = array( 'uint 64', 10000000000, 'cf00000002540be400' ); + $tests[] = array( 'int 64', -10000000000, 'd3fffffffdabf41c00' ); + $tests[] = array( 'int 64', -223372036854775807, 'd3fce66c50e2840001' ); + $tests[] = array( 'int 64', -9223372036854775807, 'd38000000000000001' ); + } + + return $tests; + } + + /** + * Verify that values are serialized correctly. + * @covers MWMessagePack::pack + * @dataProvider providePacks + */ + public function testPack( $type, $value, $expected ) { + $actual = bin2hex( MWMessagePack::pack( $value ) ); + $this->assertEquals( $expected, $actual, $type ); + } +} diff --git a/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php new file mode 100644 index 00000000..1a8a1e56 --- /dev/null +++ b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php @@ -0,0 +1,237 @@ +<?php + +/** + * Test for ProcessCacheLRU class. + * + * Note that it uses the ProcessCacheLRUTestable class which extends some + * properties and methods visibility. That class is defined at the end of the + * file containing this class. + * + * @group Cache + */ +class ProcessCacheLRUTest extends MediaWikiTestCase { + + /** + * Helper to verify emptiness of a cache object. + * Compare against an array so we get the cache content difference. + */ + function assertCacheEmpty( $cache, $msg = 'Cache should be empty' ) { + $this->assertAttributeEquals( array(), 'cache', $cache, $msg ); + } + + /** + * Helper to fill a cache object passed by reference + */ + function fillCache( &$cache, $numEntries ) { + // Fill cache with three values + for ( $i = 1; $i <= $numEntries; $i++ ) { + $cache->set( "cache-key-$i", "prop-$i", "value-$i" ); + } + } + + /** + * Generates an array of what would be expected in cache for a given cache + * size and a number of entries filled in sequentially + */ + function getExpectedCache( $cacheMaxEntries, $entryToFill ) { + $expected = array(); + + if ( $entryToFill === 0 ) { + # The cache is empty! + return array(); + } elseif ( $entryToFill <= $cacheMaxEntries ) { + # Cache is not fully filled + $firstKey = 1; + } else { + # Cache overflowed + $firstKey = 1 + $entryToFill - $cacheMaxEntries; + } + + $lastKey = $entryToFill; + + for ( $i = $firstKey; $i <= $lastKey; $i++ ) { + $expected["cache-key-$i"] = array( "prop-$i" => "value-$i" ); + } + + return $expected; + } + + /** + * Highlight diff between assertEquals and assertNotSame + */ + public function testPhpUnitArrayEquality() { + $one = array( 'A' => 1, 'B' => 2 ); + $two = array( 'B' => 2, 'A' => 1 ); + $this->assertEquals( $one, $two ); // == + $this->assertNotSame( $one, $two ); // === + } + + /** + * @dataProvider provideInvalidConstructorArg + * @expectedException UnexpectedValueException + */ + public function testConstructorGivenInvalidValue( $maxSize ) { + new ProcessCacheLRUTestable( $maxSize ); + } + + /** + * Value which are forbidden by the constructor + */ + public static function provideInvalidConstructorArg() { + return array( + array( null ), + array( array() ), + array( new stdClass() ), + array( 0 ), + array( '5' ), + array( -1 ), + ); + } + + public function testAddAndGetAKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + // First set just one value + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $this->assertEquals( 1, $oneCache->getEntriesCount() ); + $this->assertTrue( $oneCache->has( 'cache-key', 'prop1' ) ); + $this->assertEquals( 'value1', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + public function testDeleteOldKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $oneCache->set( 'cache-key', 'prop1', 'value2' ); + $this->assertEquals( 'value2', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + /** + * This test that we properly overflow when filling a cache with + * a sequence of always different cache-keys. Meant to verify we correclty + * delete the older key. + * + * @dataProvider provideCacheFilling + * @param int $cacheMaxEntries Maximum entry the created cache will hold + * @param int $entryToFill Number of entries to insert in the created cache. + */ + public function testFillingCache( $cacheMaxEntries, $entryToFill, $msg = '' ) { + $cache = new ProcessCacheLRUTestable( $cacheMaxEntries ); + $this->fillCache( $cache, $entryToFill ); + + $this->assertSame( + $this->getExpectedCache( $cacheMaxEntries, $entryToFill ), + $cache->getCache(), + "Filling a $cacheMaxEntries entries cache with $entryToFill entries" + ); + } + + /** + * Provider for testFillingCache + */ + public static function provideCacheFilling() { + // ($cacheMaxEntries, $entryToFill, $msg='') + return array( + array( 1, 0 ), + array( 1, 1 ), + array( 1, 2 ), # overflow + array( 5, 33 ), # overflow + ); + } + + /** + * Create a cache with only one remaining entry then update + * the first inserted entry. Should bump it to the top. + */ + public function testReplaceExistingKeyShouldBumpEntryToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + // Fill cache leaving just one remaining slot + $this->fillCache( $cache, $maxEntries - 1 ); + + // Set an existing cache key + $cache->set( "cache-key-1", "prop-1", "new-value-for-1" ); + + $this->assertSame( + array( + 'cache-key-2' => array( 'prop-2' => 'value-2' ), + 'cache-key-1' => array( 'prop-1' => 'new-value-for-1' ), + ), + $cache->getCache() + ); + } + + public function testRecentlyAccessedKeyStickIn() { + $cache = new ProcessCacheLRUTestable( 2 ); + $cache->set( 'first', 'prop1', 'value1' ); + $cache->set( 'second', 'prop2', 'value2' ); + + // Get first + $cache->get( 'first', 'prop1' ); + // Cache a third value, should invalidate the least used one + $cache->set( 'third', 'prop3', 'value3' ); + + $this->assertFalse( $cache->has( 'second', 'prop2' ) ); + } + + /** + * This first create a full cache then update the value for the 2nd + * filled entry. + * Given a cache having 1,2,3 as key, updating 2 should bump 2 to + * the top of the queue with the new value: 1,3,2* (* = updated). + */ + public function testReplaceExistingKeyInAFullCacheShouldBumpToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + $this->fillCache( $cache, $maxEntries ); + + // Set an existing cache key + $cache->set( "cache-key-2", "prop-2", "new-value-for-2" ); + $this->assertSame( + array( + 'cache-key-1' => array( 'prop-1' => 'value-1' ), + 'cache-key-3' => array( 'prop-3' => 'value-3' ), + 'cache-key-2' => array( 'prop-2' => 'new-value-for-2' ), + ), + $cache->getCache() + ); + $this->assertEquals( 'new-value-for-2', + $cache->get( 'cache-key-2', 'prop-2' ) + ); + } + + public function testBumpExistingKeyToTop() { + $cache = new ProcessCacheLRUTestable( 3 ); + $this->fillCache( $cache, 3 ); + + // Set the very first cache key to a new value + $cache->set( "cache-key-1", "prop-1", "new value for 1" ); + $this->assertEquals( + array( + 'cache-key-2' => array( 'prop-2' => 'value-2' ), + 'cache-key-3' => array( 'prop-3' => 'value-3' ), + 'cache-key-1' => array( 'prop-1' => 'new value for 1' ), + ), + $cache->getCache() + ); + } +} + +/** + * Overrides some ProcessCacheLRU methods and properties accessibility. + */ +class ProcessCacheLRUTestable extends ProcessCacheLRU { + public $cache = array(); + + public function getCache() { + return $this->cache; + } + + public function getEntriesCount() { + return count( $this->cache ); + } +} diff --git a/tests/phpunit/includes/libs/RunningStatTest.php b/tests/phpunit/includes/libs/RunningStatTest.php new file mode 100644 index 00000000..dc5db82c --- /dev/null +++ b/tests/phpunit/includes/libs/RunningStatTest.php @@ -0,0 +1,79 @@ +<?php +/** + * PHP Unit tests for RunningStat class. + * @covers RunningStat + */ +class RunningStatTest extends MediaWikiTestCase { + + public $points = array( + 49.7168, 74.3804, 7.0115, 96.5769, 34.9458, + 36.9947, 33.8926, 89.0774, 23.7745, 73.5154, + 86.1322, 53.2124, 16.2046, 73.5130, 10.4209, + 42.7299, 49.3330, 47.0215, 34.9950, 18.2914, + ); + + /** + * Verify that the statistical moments and extrema computed by RunningStat + * match expected values. + * @covers RunningStat::push + * @covers RunningStat::count + * @covers RunningStat::getMean + * @covers RunningStat::getVariance + * @covers RunningStat::getStdDev + */ + public function testRunningStatAccuracy() { + $rstat = new RunningStat(); + foreach( $this->points as $point ) { + $rstat->push( $point ); + } + + $mean = array_sum( $this->points ) / count( $this->points ); + $variance = array_sum( array_map( function ( $x ) use ( $mean ) { + return pow( $mean - $x, 2 ); + }, $this->points ) ) / ( count( $rstat ) - 1 ); + $stddev = sqrt( $variance ); + + $this->assertEquals( count( $rstat ), count( $this->points ) ); + $this->assertEquals( $rstat->min, min( $this->points ) ); + $this->assertEquals( $rstat->max, max( $this->points ) ); + $this->assertEquals( $rstat->getMean(), $mean ); + $this->assertEquals( $rstat->getVariance(), $variance ); + $this->assertEquals( $rstat->getStdDev(), $stddev ); + } + + /** + * When one RunningStat instance is merged into another, the state of the + * target RunningInstance should have the state that it would have had if + * all the data had been accumulated by it alone. + * @covers RunningStat::merge + * @covers RunningStat::count + */ + public function testRunningStatMerge() { + $expected = new RunningStat(); + + foreach( $this->points as $point ) { + $expected->push( $point ); + } + + // Split the data into two sets + $sets = array_chunk( $this->points, floor( count( $this->points ) / 2 ) ); + + // Accumulate the first half into one RunningStat object + $first = new RunningStat(); + foreach( $sets[0] as $point ) { + $first->push( $point ); + } + + // Accumulate the second half into another RunningStat object + $second = new RunningStat(); + foreach( $sets[1] as $point ) { + $second->push( $point ); + } + + // Merge the second RunningStat object into the first + $first->merge( $second ); + + $this->assertEquals( count( $first ), count( $this->points ) ); + $this->assertEquals( $first, $expected ); + } +} diff --git a/tests/phpunit/includes/logging/LogFormatterTest.php b/tests/phpunit/includes/logging/LogFormatterTest.php new file mode 100644 index 00000000..6210d098 --- /dev/null +++ b/tests/phpunit/includes/logging/LogFormatterTest.php @@ -0,0 +1,242 @@ +<?php +/** + * @group Database + */ +class LogFormatterTest extends MediaWikiLangTestCase { + + /** + * @var User + */ + protected $user; + + /** + * @var Title + */ + protected $title; + + /** + * @var RequestContext + */ + protected $context; + + protected function setUp() { + parent::setUp(); + + global $wgLang; + + $this->setMwGlobals( array( + 'wgLogTypes' => array( 'phpunit' ), + 'wgLogActionsHandlers' => array( 'phpunit/test' => 'LogFormatter', + 'phpunit/param' => 'LogFormatter' ), + 'wgUser' => User::newFromName( 'Testuser' ), + 'wgExtensionMessagesFiles' => array( 'LogTests' => __DIR__ . '/LogTests.i18n.php' ), + ) ); + + Language::getLocalisationCache()->recache( $wgLang->getCode() ); + + $this->user = User::newFromName( 'Testuser' ); + $this->title = Title::newMainPage(); + + $this->context = new RequestContext(); + $this->context->setUser( $this->user ); + $this->context->setTitle( $this->title ); + $this->context->setLanguage( $wgLang ); + } + + protected function tearDown() { + parent::tearDown(); + + global $wgLang; + Language::getLocalisationCache()->recache( $wgLang->getCode() ); + } + + public function newLogEntry( $action, $params ) { + $logEntry = new ManualLogEntry( 'phpunit', $action ); + $logEntry->setPerformer( $this->user ); + $logEntry->setTarget( $this->title ); + $logEntry->setComment( 'A very good reason' ); + + $logEntry->setParameters( $params ); + + return $logEntry; + } + + /** + * @covers LogFormatter::newFromEntry + */ + public function testNormalLogParams() { + $entry = $this->newLogEntry( 'test', array() ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $formatter->setShowUserToolLinks( false ); + $paramsWithoutTools = $formatter->getMessageParametersForTesting(); + unset( $formatter->parsedParameters ); + + $formatter->setShowUserToolLinks( true ); + $paramsWithTools = $formatter->getMessageParametersForTesting(); + + $userLink = Linker::userLink( + $this->user->getId(), + $this->user->getName() + ); + + $userTools = Linker::userToolLinksRedContribs( + $this->user->getId(), + $this->user->getName(), + $this->user->getEditCount() + ); + + $titleLink = Linker::link( $this->title, null, array(), array() ); + + // $paramsWithoutTools and $paramsWithTools should be only different + // in index 0 + $this->assertEquals( $paramsWithoutTools[1], $paramsWithTools[1] ); + $this->assertEquals( $paramsWithoutTools[2], $paramsWithTools[2] ); + + $this->assertEquals( $userLink, $paramsWithoutTools[0]['raw'] ); + $this->assertEquals( $userLink . $userTools, $paramsWithTools[0]['raw'] ); + + $this->assertEquals( $this->user->getName(), $paramsWithoutTools[1] ); + + $this->assertEquals( $titleLink, $paramsWithoutTools[2]['raw'] ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeRaw() { + $params = array( '4:raw:raw' => Linker::link( $this->title, null, array(), array() ) ); + $expected = Linker::link( $this->title, null, array(), array() ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeMsg() { + $params = array( '4:msg:msg' => 'log-description-phpunit' ); + $expected = wfMessage( 'log-description-phpunit' )->text(); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeMsgContent() { + $params = array( '4:msg-content:msgContent' => 'log-description-phpunit' ); + $expected = wfMessage( 'log-description-phpunit' )->inContentLanguage()->text(); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeNumber() { + global $wgLang; + + $params = array( '4:number:number' => 123456789 ); + $expected = $wgLang->formatNum( 123456789 ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeUserLink() { + $params = array( '4:user-link:userLink' => $this->user->getName() ); + $expected = Linker::userLink( + $this->user->getId(), + $this->user->getName() + ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypeTitleLink() { + $params = array( '4:title-link:titleLink' => $this->title->getText() ); + $expected = Linker::link( $this->title, null, array(), array() ); + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getActionText + */ + public function testLogParamsTypePlain() { + $params = array( '4:plain:plain' => 'Some plain text' ); + $expected = 'Some plain text'; + + $entry = $this->newLogEntry( 'param', $params ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $logParam = $formatter->getActionText(); + + $this->assertEquals( $expected, $logParam ); + } + + /** + * @covers LogFormatter::newFromEntry + * @covers LogFormatter::getComment + */ + public function testLogComment() { + $entry = $this->newLogEntry( 'test', array() ); + $formatter = LogFormatter::newFromEntry( $entry ); + $formatter->setContext( $this->context ); + + $comment = ltrim( Linker::commentBlock( $entry->getComment() ) ); + + $this->assertEquals( $comment, $formatter->getComment() ); + } +} diff --git a/tests/phpunit/includes/logging/LogTests.i18n.php b/tests/phpunit/includes/logging/LogTests.i18n.php new file mode 100644 index 00000000..78787ba1 --- /dev/null +++ b/tests/phpunit/includes/logging/LogTests.i18n.php @@ -0,0 +1,15 @@ +<?php +/** + * Internationalisation file for log tests. + * + * @file + */ + +$messages = array(); + +$messages['en'] = array( + 'log-name-phpunit' => 'PHPUnit-log', + 'log-description-phpunit' => 'Log for PHPUnit-tests', + 'logentry-phpunit-test' => '$1 {{GENDER:$2|tests}} with page $3', + 'logentry-phpunit-param' => '$4', +); diff --git a/tests/phpunit/includes/mail/MailAddressTest.php b/tests/phpunit/includes/mail/MailAddressTest.php new file mode 100644 index 00000000..2d078120 --- /dev/null +++ b/tests/phpunit/includes/mail/MailAddressTest.php @@ -0,0 +1,63 @@ +<?php + +class MailAddressTest extends MediaWikiTestCase { + + /** + * @covers MailAddress::__construct + */ + public function testConstructor() { + $ma = new MailAddress( 'foo@bar.baz', 'UserName', 'Real name' ); + $this->assertInstanceOf( 'MailAddress', $ma ); + } + + /** + * @covers MailAddress::newFromUser + */ + public function testNewFromUser() { + $user = $this->getMock( 'User' ); + $user->expects( $this->any() )->method( 'getName' )->will( $this->returnValue( 'UserName' ) ); + $user->expects( $this->any() )->method( 'getEmail' )->will( $this->returnValue( 'foo@bar.baz' ) ); + $user->expects( $this->any() )->method( 'getRealName' )->will( $this->returnValue( 'Real name' ) ); + + $ma = MailAddress::newFromUser( $user ); + $this->assertInstanceOf( 'MailAddress', $ma ); + $this->setMwGlobals( 'wgEnotifUseRealName', true ); + $this->assertEquals( 'Real name <foo@bar.baz>', $ma->toString() ); + $this->setMwGlobals( 'wgEnotifUseRealName', false ); + $this->assertEquals( 'UserName <foo@bar.baz>', $ma->toString() ); + } + + /** + * @covers MailAddress::toString + * @dataProvider provideToString + */ + public function testToString( $useRealName, $address, $name, $realName, $expected ) { + if ( wfIsWindows() ) { + $this->markTestSkipped( 'This test only works on non-Windows platforms' ); + } + $this->setMwGlobals( 'wgEnotifUseRealName', $useRealName ); + $ma = new MailAddress( $address, $name, $realName ); + $this->assertEquals( $expected, $ma->toString() ); + } + + public static function provideToString() { + return array( + array( true, 'foo@bar.baz', 'FooBar', 'Foo Bar', 'Foo Bar <foo@bar.baz>' ), + array( true, 'foo@bar.baz', 'UserName', null, 'UserName <foo@bar.baz>' ), + array( true, 'foo@bar.baz', 'AUser', 'My real name', 'My real name <foo@bar.baz>' ), + array( true, 'foo@bar.baz', 'A.user.name', 'my@real.name', '"my@real.name" <foo@bar.baz>' ), + array( false, 'foo@bar.baz', 'AUserName', 'Some real name', 'AUserName <foo@bar.baz>' ), + array( false, 'foo@bar.baz', '', '', 'foo@bar.baz' ), + array( true, 'foo@bar.baz', '', '', 'foo@bar.baz' ), + ); + } + + /** + * @covers MailAddress::__toString + */ + public function test__ToString() { + $ma = new MailAddress( 'some@email.com', 'UserName', 'A real name' ); + $this->assertEquals( $ma->toString(), (string)$ma ); + } + +}
\ No newline at end of file diff --git a/tests/phpunit/includes/mail/UserMailerTest.php b/tests/phpunit/includes/mail/UserMailerTest.php new file mode 100644 index 00000000..dca8aeb9 --- /dev/null +++ b/tests/phpunit/includes/mail/UserMailerTest.php @@ -0,0 +1,14 @@ +<?php + +class UserMailerTest extends MediaWikiLangTestCase { + + /** + * @covers UserMailer::quotedPrintable + */ + public function testQuotedPrintable() { + $this->assertEquals( + "=?UTF-8?Q?=C4=88u=20legebla=3F?=", + UserMailer::quotedPrintable( "\xc4\x88u legebla?", "UTF-8" ) ); + } + +} diff --git a/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php new file mode 100644 index 00000000..c720d7b7 --- /dev/null +++ b/tests/phpunit/includes/media/BitmapMetadataHandlerTest.php @@ -0,0 +1,167 @@ +<?php + +/** + * @group Media + */ +class BitmapMetadataHandlerTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( 'wgShowEXIF', false ); + + $this->filePath = __DIR__ . '/../../data/media/'; + } + + /** + * Test if having conflicting metadata values from different + * types of metadata, that the right one takes precedence. + * + * Basically the file has IPTC and XMP metadata, the + * IPTC should override the XMP, except for the multilingual + * translation (to en) where XMP should win. + * @covers BitmapMetadataHandler::Jpeg + */ + public function testMultilingualCascade() { + $this->checkPHPExtension( 'exif' ); + $this->checkPHPExtension( 'xml' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + '/Xmp-exif-multilingual_test.jpg' ); + + $expected = array( + 'x-default' => 'right(iptc)', + 'en' => 'right translation', + '_type' => 'lang' + ); + + $this->assertArrayHasKey( 'ImageDescription', $meta, + 'Did not extract any ImageDescription info?!' ); + + $this->assertEquals( $expected, $meta['ImageDescription'] ); + } + + /** + * Test for jpeg comments are being handled by + * BitmapMetadataHandler correctly. + * + * There's more extensive tests of comment extraction in + * JpegMetadataExtractorTests.php + * @covers BitmapMetadataHandler::Jpeg + */ + public function testJpegComment() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'jpeg-comment-utf.jpg' ); + + $this->assertEquals( 'UTF-8 JPEG Comment — ¼', + $meta['JPEGFileComment'][0] ); + } + + /** + * Make sure a bad iptc block doesn't stop the other metadata + * from being extracted. + * @covers BitmapMetadataHandler::Jpeg + */ + public function testBadIPTC() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-invalid-psir.jpg' ); + $this->assertEquals( 'Created with GIMP', $meta['JPEGFileComment'][0] ); + } + + /** + * @covers BitmapMetadataHandler::Jpeg + */ + public function testIPTCDates() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-timetest.jpg' ); + + $this->assertEquals( '2020:07:14 01:36:05', $meta['DateTimeDigitized'] ); + $this->assertEquals( '1997:03:02 00:01:02', $meta['DateTimeOriginal'] ); + } + + /** + * File has an invalid time (+ one valid but really weird time) + * that shouldn't be included + * @covers BitmapMetadataHandler::Jpeg + */ + public function testIPTCDatesInvalid() { + $meta = BitmapMetadataHandler::Jpeg( $this->filePath . + 'iptc-timetest-invalid.jpg' ); + + $this->assertEquals( '1845:03:02 00:01:02', $meta['DateTimeOriginal'] ); + $this->assertFalse( isset( $meta['DateTimeDigitized'] ) ); + } + + /** + * XMP data should take priority over iptc data + * when hash has been updated, but not when + * the hash is wrong. + * @covers BitmapMetadataHandler::addMetadata + * @covers BitmapMetadataHandler::getMetadataArray + */ + public function testMerging() { + $merger = new BitmapMetadataHandler(); + $merger->addMetadata( array( 'foo' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'bar' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'baz' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'fred' => 'xmp' ), 'xmp-general' ); + $merger->addMetadata( array( 'foo' => 'iptc (hash)' ), 'iptc-good-hash' ); + $merger->addMetadata( array( 'bar' => 'iptc (bad hash)' ), 'iptc-bad-hash' ); + $merger->addMetadata( array( 'baz' => 'iptc (bad hash)' ), 'iptc-bad-hash' ); + $merger->addMetadata( array( 'fred' => 'iptc (no hash)' ), 'iptc-no-hash' ); + $merger->addMetadata( array( 'baz' => 'exif' ), 'exif' ); + + $actual = $merger->getMetadataArray(); + $expected = array( + 'foo' => 'xmp', + 'bar' => 'iptc (bad hash)', + 'baz' => 'exif', + 'fred' => 'xmp', + ); + $this->assertEquals( $expected, $actual ); + } + + /** + * @covers BitmapMetadataHandler::png + */ + public function testPNGXMP() { + if ( !extension_loaded( 'xml' ) ) { + $this->markTestSkipped( "This test needs the xml extension." ); + } + $handler = new BitmapMetadataHandler(); + $result = $handler->png( $this->filePath . 'xmp.png' ); + $expected = array( + 'frameCount' => 0, + 'loopCount' => 1, + 'duration' => 0, + 'bitDepth' => 1, + 'colorType' => 'index-coloured', + 'metadata' => array( + 'SerialNumber' => '123456789', + '_MW_PNG_VERSION' => 1, + ), + ); + $this->assertEquals( $expected, $result ); + } + + /** + * @covers BitmapMetadataHandler::png + */ + public function testPNGNative() { + $handler = new BitmapMetadataHandler(); + $result = $handler->png( $this->filePath . 'Png-native-test.png' ); + $expected = 'http://example.com/url'; + $this->assertEquals( $expected, $result['metadata']['Identifier']['x-default'] ); + } + + /** + * @covers BitmapMetadataHandler::getTiffByteOrder + */ + public function testTiffByteOrder() { + $handler = new BitmapMetadataHandler(); + $res = $handler->getTiffByteOrder( $this->filePath . 'test.tiff' ); + $this->assertEquals( 'LE', $res ); + } +} diff --git a/tests/phpunit/includes/media/BitmapScalingTest.php b/tests/phpunit/includes/media/BitmapScalingTest.php new file mode 100644 index 00000000..1972c969 --- /dev/null +++ b/tests/phpunit/includes/media/BitmapScalingTest.php @@ -0,0 +1,140 @@ +<?php + +/** + * @group Media + */ +class BitmapScalingTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgMaxImageArea' => 1.25e7, // 3500x3500 + 'wgCustomConvertCommand' => 'dummy', // Set so that we don't get client side rendering + ) ); + } + + /** + * @dataProvider provideNormaliseParams + * @covers BitmapHandler::normaliseParams + */ + public function testNormaliseParams( $fileDimensions, $expectedParams, $params, $msg ) { + $file = new FakeDimensionFile( $fileDimensions ); + $handler = new BitmapHandler; + $valid = $handler->normaliseParams( $file, $params ); + $this->assertTrue( $valid ); + $this->assertEquals( $expectedParams, $params, $msg ); + } + + public static function provideNormaliseParams() { + return array( + /* Regular resize operations */ + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 512 ), + 'Resizing with width set', + ), + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 512, 'height' => 768 ), + 'Resizing with height set too high', + ), + array( + array( 1024, 768 ), + array( + 'width' => 512, 'height' => 384, + 'physicalWidth' => 512, 'physicalHeight' => 384, + 'page' => 1, + ), + array( 'width' => 1024, 'height' => 384 ), + 'Resizing with height set', + ), + + /* Very tall images */ + array( + array( 1000, 100 ), + array( + 'width' => 5, 'height' => 1, + 'physicalWidth' => 5, 'physicalHeight' => 1, + 'page' => 1, + ), + array( 'width' => 5 ), + 'Very wide image', + ), + + array( + array( 100, 1000 ), + array( + 'width' => 1, 'height' => 10, + 'physicalWidth' => 1, 'physicalHeight' => 10, + 'page' => 1, + ), + array( 'width' => 1 ), + 'Very high image', + ), + array( + array( 100, 1000 ), + array( + 'width' => 1, 'height' => 5, + 'physicalWidth' => 1, 'physicalHeight' => 10, + 'page' => 1, + ), + array( 'width' => 10, 'height' => 5 ), + 'Very high image with height set', + ), + /* Max image area */ + array( + array( 4000, 4000 ), + array( + 'width' => 5000, 'height' => 5000, + 'physicalWidth' => 4000, 'physicalHeight' => 4000, + 'page' => 1, + ), + array( 'width' => 5000 ), + 'Bigger than max image size but doesn\'t need scaling', + ), + ); + } + + /** + * @covers BitmapHandler::doTransform + */ + public function testTooBigImage() { + $file = new FakeDimensionFile( array( 4000, 4000 ) ); + $handler = new BitmapHandler; + $params = array( 'width' => '3700' ); // Still bigger than max size. + $this->assertEquals( 'TransformParameterError', + get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) ); + } + + /** + * @covers BitmapHandler::doTransform + */ + public function testTooBigMustRenderImage() { + $file = new FakeDimensionFile( array( 4000, 4000 ) ); + $file->mustRender = true; + $handler = new BitmapHandler; + $params = array( 'width' => '5000' ); // Still bigger than max size. + $this->assertEquals( 'TransformParameterError', + get_class( $handler->doTransform( $file, 'dummy path', '', $params ) ) ); + } + + /** + * @covers BitmapHandler::getImageArea + */ + public function testImageArea() { + $file = new FakeDimensionFile( array( 7, 9 ) ); + $handler = new BitmapHandler; + $this->assertEquals( 63, $handler->getImageArea( $file ) ); + } +} diff --git a/tests/phpunit/includes/media/DjVuTest.php b/tests/phpunit/includes/media/DjVuTest.php new file mode 100644 index 00000000..c0871f19 --- /dev/null +++ b/tests/phpunit/includes/media/DjVuTest.php @@ -0,0 +1,69 @@ +<?php +/** + * @group Media + * @covers DjVuHandler + */ +class DjVuTest extends MediaWikiMediaTestCase { + + /** + * @var DjVuHandler + */ + protected $handler; + + protected function setUp() { + parent::setUp(); + + //cli tool setup + $djvuSupport = new DjVuSupport(); + + if ( !$djvuSupport->isEnabled() ) { + $this->markTestSkipped( + 'This test needs the installation of the ddjvu, djvutoxml and djvudump tools' ); + } + + $this->handler = new DjVuHandler(); + } + + public function testGetImageSize() { + $this->assertArrayEquals( + array( 2480, 3508, 'DjVu', 'width="2480" height="3508"' ), + $this->handler->getImageSize( null, $this->filePath . '/LoremIpsum.djvu' ), + 'Test file LoremIpsum.djvu should have a size of 2480 * 3508' + ); + } + + public function testInvalidFile() { + $this->assertEquals( + 'a:1:{s:5:"error";s:25:"Error extracting metadata";}', + $this->handler->getMetadata( null, $this->filePath . '/some-nonexistent-file' ), + 'Getting metadata for an inexistent file should return false' + ); + } + + public function testPageCount() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertEquals( + 5, + $this->handler->pageCount( $file ), + 'Test file LoremIpsum.djvu should be detected as containing 5 pages' + ); + } + + public function testGetPageDimensions() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertArrayEquals( + array( 2480, 3508 ), + $this->handler->getPageDimensions( $file, 1 ), + 'Page 1 of test file LoremIpsum.djvu should have a size of 2480 * 3508' + ); + } + + public function testGetPageText() { + $file = $this->dataFile( 'LoremIpsum.djvu', 'image/x.djvu' ); + $this->assertEquals( + "Lorem ipsum \n1 \n", + (string)$this->handler->getPageText( $file, 1 ), + "Text layer of page 1 of file LoremIpsum.djvu should be 'Lorem ipsum \n1 \n'" + ); + } +} diff --git a/tests/phpunit/includes/media/ExifBitmapTest.php b/tests/phpunit/includes/media/ExifBitmapTest.php new file mode 100644 index 00000000..41330f41 --- /dev/null +++ b/tests/phpunit/includes/media/ExifBitmapTest.php @@ -0,0 +1,146 @@ +<?php + +/** + * @group Media + */ +class ExifBitmapTest extends MediaWikiTestCase { + + /** + * @var ExifBitmapHandler + */ + protected $handler; + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new ExifBitmapHandler; + + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsOldBroken() { + $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::OLD_BROKEN_FILE ); + $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsBrokenFile() { + $res = $this->handler->isMetadataValid( null, ExifBitmapHandler::BROKEN_FILE ); + $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsInvalid() { + $res = $this->handler->isMetadataValid( null, 'Something Invalid Here.' ); + $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testGoodMetadata() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_GOOD, $res ); + } + + /** + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testIsOldGood() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_COMPATIBLE, $res ); + } + + /** + * Handle metadata from paged tiff handler (gotten via instant commons) gracefully. + * @covers ExifBitmapHandler::isMetadataValid + */ + public function testPagedTiffHandledGracefully() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $meta = 'a:6:{s:9:"page_data";a:1:{i:1;a:5:{s:5:"width";i:643;s:6:"height";i:448;s:5:"alpha";s:4:"true";s:4:"page";i:1;s:6:"pixels";i:288064;}}s:10:"page_count";i:1;s:10:"first_page";i:1;s:9:"last_page";i:1;s:4:"exif";a:9:{s:10:"ImageWidth";i:643;s:11:"ImageLength";i:448;s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:4;s:12:"RowsPerStrip";i:50;s:19:"PlanarConfiguration";i:1;s:22:"MEDIAWIKI_EXIF_VERSION";i:1;}s:21:"TIFF_METADATA_VERSION";s:3:"1.4";}'; + // @codingStandardsIgnoreEnd + $res = $this->handler->isMetadataValid( null, $meta ); + $this->assertEquals( ExifBitmapHandler::METADATA_BAD, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataLatest() { + $metadata = array( + 'foo' => array( 'First', 'Second', '_type' => 'ol' ), + 'MEDIAWIKI_EXIF_VERSION' => 2 + ); + $res = $this->handler->convertMetadataVersion( $metadata, 2 ); + $this->assertEquals( $metadata, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataToOld() { + $metadata = array( + 'foo' => array( 'First', 'Second', '_type' => 'ol' ), + 'bar' => array( 'First', 'Second', '_type' => 'ul' ), + 'baz' => array( 'First', 'Second' ), + 'fred' => 'Single', + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'foo' => "\n#First\n#Second", + 'bar' => "\n*First\n*Second", + 'baz' => "\n*First\n*Second", + 'fred' => 'Single', + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataSoftware() { + $metadata = array( + 'Software' => array( array( 'GIMP', '1.1' ) ), + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'Software' => 'GIMP (Version 1.1)', + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } + + /** + * @covers ExifBitmapHandler::convertMetadataVersion + */ + public function testConvertMetadataSoftwareNormal() { + $metadata = array( + 'Software' => array( "GIMP 1.2", "vim" ), + 'MEDIAWIKI_EXIF_VERSION' => 2, + ); + $expected = array( + 'Software' => "\n*GIMP 1.2\n*vim", + 'MEDIAWIKI_EXIF_VERSION' => 1, + ); + $res = $this->handler->convertMetadataVersion( $metadata, 1 ); + $this->assertEquals( $expected, $res ); + } +} diff --git a/tests/phpunit/includes/media/ExifRotationTest.php b/tests/phpunit/includes/media/ExifRotationTest.php new file mode 100644 index 00000000..f0bd42a0 --- /dev/null +++ b/tests/phpunit/includes/media/ExifRotationTest.php @@ -0,0 +1,280 @@ +<?php +/** + * Tests related to auto rotation. + * + * @group Media + * @group medium + * + * @todo covers tags + */ +class ExifRotationTest extends MediaWikiMediaTestCase { + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); + + $this->handler = new BitmapHandler(); + + $this->setMwGlobals( array( + 'wgShowEXIF' => true, + 'wgEnableAutoRotation' => true, + ) ); + } + + /** + * Mark this test as creating thumbnail files. + */ + protected function createsThumbnails() { + return true; + } + + /** + * @dataProvider provideFiles + */ + public function testMetadata( $name, $type, $info ) { + if ( !$this->handler->canRotate() ) { + $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." ); + } + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * Same as before, but with auto-rotation set to auto. + * + * This sets scaler to image magick, which we should detect as + * supporting rotation. + * @dataProvider provideFiles + */ + public function testMetadataAutoRotate( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', null ); + $this->setMwGlobals( 'wgUseImageMagick', true ); + $this->setMwGlobals( 'wgUseImageResize', true ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * + * @dataProvider provideFiles + */ + public function testRotationRendering( $name, $type, $info, $thumbs ) { + if ( !$this->handler->canRotate() ) { + $this->markTestSkipped( "This test needs a rasterizer that can auto-rotate." ); + } + foreach ( $thumbs as $size => $out ) { + if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + ); + } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + 'height' => $matches[2] + ); + } else { + throw new MWException( 'bogus test data format ' . $size ); + } + + $file = $this->dataFile( $name, $type ); + $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE ); + + $this->assertEquals( + $out[0], + $thumb->getWidth(), + "$name: thumb reported width check for $size" + ); + $this->assertEquals( + $out[1], + $thumb->getHeight(), + "$name: thumb reported height check for $size" + ); + + $gis = getimagesize( $thumb->getLocalCopyPath() ); + if ( $out[0] > $info['width'] ) { + // Physical image won't be scaled bigger than the original. + $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" ); + } else { + $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" ); + } + } + } + + public static function provideFiles() { + return array( + array( + 'landscape-plain.jpg', + 'image/jpeg', + array( + 'width' => 1024, + 'height' => 768, + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ), + array( + 'portrait-rotated.jpg', + 'image/jpeg', + array( + 'width' => 768, // as rotated + 'height' => 1024, // as rotated + ), + array( + '800x600px' => array( 450, 600 ), + '9999x800px' => array( 600, 800 ), + '800px' => array( 800, 1067 ), + '600px' => array( 600, 800 ), + ) + ) + ); + } + + /** + * Same as before, but with auto-rotation disabled. + * @dataProvider provideFilesNoAutoRotate + */ + public function testMetadataNoAutoRotate( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', false ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * Same as before, but with auto-rotation set to auto and an image scaler that doesn't support it. + * @dataProvider provideFilesNoAutoRotate + */ + public function testMetadataAutoRotateUnsupported( $name, $type, $info ) { + $this->setMwGlobals( 'wgEnableAutoRotation', null ); + $this->setMwGlobals( 'wgUseImageResize', false ); + + $file = $this->dataFile( $name, $type ); + $this->assertEquals( $info['width'], $file->getWidth(), "$name: width check" ); + $this->assertEquals( $info['height'], $file->getHeight(), "$name: height check" ); + } + + /** + * + * @dataProvider provideFilesNoAutoRotate + */ + public function testRotationRenderingNoAutoRotate( $name, $type, $info, $thumbs ) { + $this->setMwGlobals( 'wgEnableAutoRotation', false ); + + foreach ( $thumbs as $size => $out ) { + if ( preg_match( '/^(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + ); + } elseif ( preg_match( '/^(\d+)x(\d+)px$/', $size, $matches ) ) { + $params = array( + 'width' => $matches[1], + 'height' => $matches[2] + ); + } else { + throw new MWException( 'bogus test data format ' . $size ); + } + + $file = $this->dataFile( $name, $type ); + $thumb = $file->transform( $params, File::RENDER_NOW | File::RENDER_FORCE ); + + $this->assertEquals( + $out[0], + $thumb->getWidth(), + "$name: thumb reported width check for $size" + ); + $this->assertEquals( + $out[1], + $thumb->getHeight(), + "$name: thumb reported height check for $size" + ); + + $gis = getimagesize( $thumb->getLocalCopyPath() ); + if ( $out[0] > $info['width'] ) { + // Physical image won't be scaled bigger than the original. + $this->assertEquals( $info['width'], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $info['height'], $gis[1], "$name: thumb actual height check for $size" ); + } else { + $this->assertEquals( $out[0], $gis[0], "$name: thumb actual width check for $size" ); + $this->assertEquals( $out[1], $gis[1], "$name: thumb actual height check for $size" ); + } + } + } + + public static function provideFilesNoAutoRotate() { + return array( + array( + 'landscape-plain.jpg', + 'image/jpeg', + array( + 'width' => 1024, + 'height' => 768, + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ), + array( + 'portrait-rotated.jpg', + 'image/jpeg', + array( + 'width' => 1024, // since not rotated + 'height' => 768, // since not rotated + ), + array( + '800x600px' => array( 800, 600 ), + '9999x800px' => array( 1067, 800 ), + '800px' => array( 800, 600 ), + '600px' => array( 600, 450 ), + ) + ) + ); + } + + const TEST_WIDTH = 100; + const TEST_HEIGHT = 200; + + /** + * @dataProvider provideBitmapExtractPreRotationDimensions + */ + public function testBitmapExtractPreRotationDimensions( $rotation, $expected ) { + $result = $this->handler->extractPreRotationDimensions( array( + 'physicalWidth' => self::TEST_WIDTH, + 'physicalHeight' => self::TEST_HEIGHT, + ), $rotation ); + $this->assertEquals( $expected, $result ); + } + + public static function provideBitmapExtractPreRotationDimensions() { + return array( + array( + 0, + array( self::TEST_WIDTH, self::TEST_HEIGHT ) + ), + array( + 90, + array( self::TEST_HEIGHT, self::TEST_WIDTH ) + ), + array( + 180, + array( self::TEST_WIDTH, self::TEST_HEIGHT ) + ), + array( + 270, + array( self::TEST_HEIGHT, self::TEST_WIDTH ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/ExifTest.php b/tests/phpunit/includes/media/ExifTest.php new file mode 100644 index 00000000..f3c05fb1 --- /dev/null +++ b/tests/phpunit/includes/media/ExifTest.php @@ -0,0 +1,47 @@ +<?php + +/** + * @group Media + * @covers Exif + */ +class ExifTest extends MediaWikiTestCase { + + /** @var string */ + protected $mediaPath; + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); + + $this->mediaPath = __DIR__ . '/../../data/media/'; + + $this->setMwGlobals( 'wgShowEXIF', true ); + } + + public function testGPSExtraction() { + $filename = $this->mediaPath . 'exif-gps.jpg'; + $seg = JpegMetadataExtractor::segmentSplitter( $filename ); + $exif = new Exif( $filename, $seg['byteOrder'] ); + $data = $exif->getFilteredData(); + $expected = array( + 'GPSLatitude' => 88.5180555556, + 'GPSLongitude' => -21.12357, + 'GPSAltitude' => -3.141592653, + 'GPSDOP' => '5/1', + 'GPSVersionID' => '2.2.0.0', + ); + $this->assertEquals( $expected, $data, '', 0.0000000001 ); + } + + public function testUnicodeUserComment() { + $filename = $this->mediaPath . 'exif-user-comment.jpg'; + $seg = JpegMetadataExtractor::segmentSplitter( $filename ); + $exif = new Exif( $filename, $seg['byteOrder'] ); + $data = $exif->getFilteredData(); + + $expected = array( + 'UserComment' => 'test⁔comment' + ); + $this->assertEquals( $expected, $data ); + } +} diff --git a/tests/phpunit/includes/media/FakeDimensionFile.php b/tests/phpunit/includes/media/FakeDimensionFile.php new file mode 100644 index 00000000..4b8f213e --- /dev/null +++ b/tests/phpunit/includes/media/FakeDimensionFile.php @@ -0,0 +1,31 @@ +<?php + +/** + * @group Media + */ +class FakeDimensionFile extends File { + public $mustRender = false; + + public function __construct( $dimensions ) { + parent::__construct( Title::makeTitle( NS_FILE, 'Test' ), + new NullRepo( null ) ); + + $this->dimensions = $dimensions; + } + + public function getWidth( $page = 1 ) { + return $this->dimensions[0]; + } + + public function getHeight( $page = 1 ) { + return $this->dimensions[1]; + } + + public function mustRender() { + return $this->mustRender; + } + + public function getPath() { + return ''; + } +} diff --git a/tests/phpunit/includes/media/FormatMetadataTest.php b/tests/phpunit/includes/media/FormatMetadataTest.php new file mode 100644 index 00000000..002e2cb9 --- /dev/null +++ b/tests/phpunit/includes/media/FormatMetadataTest.php @@ -0,0 +1,71 @@ +<?php + +/** + * @group Media + */ +class FormatMetadataTest extends MediaWikiMediaTestCase { + + protected function setUp() { + parent::setUp(); + + $this->checkPHPExtension( 'exif' ); + $this->setMwGlobals( 'wgShowEXIF', true ); + } + + /** + * @covers File::formatMetadata + */ + public function testInvalidDate() { + $file = $this->dataFile( 'broken_exif_date.jpg', 'image/jpeg' ); + + // Throws an error if bug hit + $meta = $file->formatMetadata(); + $this->assertNotEquals( false, $meta, 'Valid metadata extracted' ); + + // Find date exif entry + $this->assertArrayHasKey( 'visible', $meta ); + $dateIndex = null; + foreach ( $meta['visible'] as $i => $data ) { + if ( $data['id'] == 'exif-datetimeoriginal' ) { + $dateIndex = $i; + } + } + $this->assertNotNull( $dateIndex, 'Date entry exists in metadata' ); + $this->assertEquals( '0000:01:00 00:02:27', + $meta['visible'][$dateIndex]['value'], + 'File with invalid date metadata (bug 29471)' ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideFlattenArray + * @covers FormatMetadata::flattenArray + */ + public function testFlattenArray( $vals, $type, $noHtml, $ctx, $expected ) { + $actual = FormatMetadata::flattenArray( $vals, $type, $noHtml, $ctx ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideFlattenArray() { + return array( + array( + array( 1, 2, 3 ), 'ul', false, false, + "<ul><li>1</li>\n<li>2</li>\n<li>3</li></ul>", + ), + array( + array( 1, 2, 3 ), 'ol', false, false, + "<ol><li>1</li>\n<li>2</li>\n<li>3</li></ol>", + ), + array( + array( 1, 2, 3 ), 'ul', true, false, + "\n*1\n*2\n*3", + ), + array( + array( 1, 2, 3 ), 'ol', true, false, + "\n#1\n#2\n#3", + ), + // TODO: more test cases + ); + } +} diff --git a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php new file mode 100644 index 00000000..6aecd8b1 --- /dev/null +++ b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php @@ -0,0 +1,111 @@ +<?php + +/** + * @group Media + */ +class GIFMetadataExtractorTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->mediaPath = __DIR__ . '/../../data/media/'; + } + + /** + * Put in a file, and see if the metadata coming out is as expected. + * @param string $filename + * @param array $expected The extracted metadata. + * @dataProvider provideGetMetadata + * @covers GIFMetadataExtractor::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + + $xmpNugget = <<<EOF +<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?> +<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 7.30'> +<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'> + + <rdf:Description rdf:about='' + xmlns:Iptc4xmpCore='http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'> + <Iptc4xmpCore:Location>The interwebs</Iptc4xmpCore:Location> + </rdf:Description> + + <rdf:Description rdf:about='' + xmlns:tiff='http://ns.adobe.com/tiff/1.0/'> + <tiff:Artist>Bawolff</tiff:Artist> + <tiff:ImageDescription> + <rdf:Alt> + <rdf:li xml:lang='x-default'>A file to test GIF</rdf:li> + </rdf:Alt> + </tiff:ImageDescription> + </rdf:Description> +</rdf:RDF> +</x:xmpmeta> + + + + + + + + + + + + + + + + + + + + + + + + +<?xpacket end='w'?> +EOF; + $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat + + return array( + array( + 'nonanimated.gif', + array( + 'comment' => array( 'GIF test file ⁕ Created with GIMP' ), + 'duration' => 0.1, + 'frameCount' => 1, + 'looped' => false, + 'xmp' => '', + ) + ), + array( + 'animated.gif', + array( + 'comment' => array( 'GIF test file . Created with GIMP' ), + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'xmp' => '', + ) + ), + + array( + 'animated-xmp.gif', + array( + 'xmp' => $xmpNugget, + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'comment' => array( 'GIƒ·test·file' ), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/GIFTest.php b/tests/phpunit/includes/media/GIFTest.php new file mode 100644 index 00000000..87ffd995 --- /dev/null +++ b/tests/phpunit/includes/media/GIFTest.php @@ -0,0 +1,142 @@ +<?php + +/** + * @group Media + */ +class GIFHandlerTest extends MediaWikiMediaTestCase { + + /** @var GIFHandler */ + protected $handler; + + protected function setUp() { + parent::setUp(); + + $this->handler = new GIFHandler(); + } + + /** + * @covers GIFHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . '/README' ); + $this->assertEquals( GIFHandler::BROKEN_FILE, $res ); + } + + /** + * @param string $filename Basename of the file to check + * @param bool $expected Expected result. + * @dataProvider provideIsAnimated + * @covers GIFHandler::isAnimatedImage + */ + public function testIsAnimanted( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->isAnimatedImage( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsAnimated() { + return array( + array( 'animated.gif', true ), + array( 'nonanimated.gif', false ), + ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideGetImageArea + * @covers GIFHandler::getImageArea + */ + public function testGetImageArea( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetImageArea() { + return array( + array( 'animated.gif', 5400 ), + array( 'nonanimated.gif', 1350 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of GIFHandler + * @dataProvider provideIsMetadataValid + * @covers GIFHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( GIFHandler::BROKEN_FILE, GIFHandler::METADATA_GOOD ), + array( '', GIFHandler::METADATA_BAD ), + array( null, GIFHandler::METADATA_BAD ), + array( 'Something invalid!', GIFHandler::METADATA_BAD ), + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}', GIFHandler::METADATA_GOOD ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers GIFHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); + $this->assertEquals( unserialize( $expected ), unserialize( $actual ) ); + } + + public static function provideGetMetadata() { + return array( + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'nonanimated.gif', 'a:4:{s:10:"frameCount";i:1;s:6:"looped";b:0;s:8:"duration";d:0.1000000000000000055511151231257827021181583404541015625;s:8:"metadata";a:2:{s:14:"GIFFileComment";a:1:{i:0;s:35:"GIF test file ⁕ Created with GIMP";}s:15:"_MW_GIF_VERSION";i:1;}}' ), + array( 'animated-xmp.gif', 'a:4:{s:10:"frameCount";i:4;s:6:"looped";b:1;s:8:"duration";d:2.399999999999999911182158029987476766109466552734375;s:8:"metadata";a:5:{s:6:"Artist";s:7:"Bawolff";s:16:"ImageDescription";a:2:{s:9:"x-default";s:18:"A file to test GIF";s:5:"_type";s:4:"lang";}s:15:"SublocationDest";s:13:"The interwebs";s:14:"GIFFileComment";a:1:{i:0;s:16:"GIƒ·test·file";}s:15:"_MW_GIF_VERSION";i:1;}}' ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetIndependentMetaArray + * @covers GIFHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/gif' ); + $actual = $this->handler->getCommonMetaArray( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetIndependentMetaArray() { + return array( + array( 'nonanimated.gif', array( + 'GIFFileComment' => array( + 'GIF test file ⁕ Created with GIMP', + ), + ) ), + array( 'animated-xmp.gif', + array( + 'Artist' => 'Bawolff', + 'ImageDescription' => array( + 'x-default' => 'A file to test GIF', + '_type' => 'lang', + ), + 'SublocationDest' => 'The interwebs', + 'GIFFileComment' => + array( + 'GIƒ·test·file', + ), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/IPTCTest.php b/tests/phpunit/includes/media/IPTCTest.php new file mode 100644 index 00000000..06542cfe --- /dev/null +++ b/tests/phpunit/includes/media/IPTCTest.php @@ -0,0 +1,85 @@ +<?php + +/** + * @group Media + */ +class IPTCTest extends MediaWikiTestCase { + + /** + * @covers IPTC::getCharset + */ + public function testRecognizeUtf8() { + // utf-8 is the only one used in practise. + $res = IPTC::getCharset( "\x1b%G" ); + $this->assertEquals( 'UTF-8', $res ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharset88591() { + // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 + // This data doesn't specify a charset. We're supposed to guess + // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharset88591b() { + /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ + /* \xC3 = Ã, \xB8 = ¸ */ + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( 'ÃÃø' ), $res['Keywords'] ); + } + + /** + * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. + * What should happen is the first "\xC3\xC3" should be dropped as invalid, + * leaving \xC3\xB8, which is ø + * @covers IPTC::Parse + */ + public function testIPTCParseForcedUTFButInvalid() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" + . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( 'ø' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseNoCharsetUTF8() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } + + /** + * Testing something that has 2 values for keyword + * @covers IPTC::Parse + */ + public function testIPTCParseMulti() { + $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" + /* length */ . "\0\0\0\0\0\x0D" + . "\x1c\x02\x19" . "\x00\x01" . "\xBC" + . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼', '¼½' ), $res['Keywords'] ); + } + + /** + * @covers IPTC::Parse + */ + public function testIPTCParseUTF8() { + // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. + $iptcData = + "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::Parse( $iptcData ); + $this->assertEquals( array( '¼' ), $res['Keywords'] ); + } +} diff --git a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php new file mode 100644 index 00000000..7c977d5a --- /dev/null +++ b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php @@ -0,0 +1,111 @@ +<?php +/** + * @todo Could use a test of extended XMP segments. Hard to find programs that + * create example files, and creating my own in vim propbably wouldn't + * serve as a very good "test". (Adobe photoshop probably creates such files + * but it costs money). The implementation of it currently in MediaWiki is based + * solely on reading the standard, without any real world test files. + * + * @group Media + * @covers JpegMetadataExtractor + */ +class JpegMetadataExtractorTest extends MediaWikiTestCase { + + protected $filePath; + + protected function setUp() { + parent::setUp(); + + $this->filePath = __DIR__ . '/../../data/media/'; + } + + /** + * We also use this test to test padding bytes don't + * screw stuff up + * + * @param string $file Filename + * + * @dataProvider provideUtf8Comment + */ + public function testUtf8Comment( $file ) { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . $file ); + $this->assertEquals( array( 'UTF-8 JPEG Comment — ¼' ), $res['COM'] ); + } + + public static function provideUtf8Comment() { + return array( + array( 'jpeg-comment-utf.jpg' ), + array( 'jpeg-padding-even.jpg' ), + array( 'jpeg-padding-odd.jpg' ), + ); + } + + /** The file is iso-8859-1, but it should get auto converted */ + public function testIso88591Comment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-iso8859-1.jpg' ); + $this->assertEquals( array( 'ISO-8859-1 JPEG Comment - ¼' ), $res['COM'] ); + } + + /** Comment values that are non-textual (random binary junk) should not be shown. + * The example test file has a comment with a 0x5 byte in it which is a control character + * and considered binary junk for our purposes. + */ + public function testBinaryCommentStripped() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-binary.jpg' ); + $this->assertEmpty( $res['COM'] ); + } + + /* Very rarely a file can have multiple comments. + * Order of comments is based on order inside the file. + */ + public function testMultipleComment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-multiple.jpg' ); + $this->assertEquals( array( 'foo', 'bar' ), $res['COM'] ); + } + + public function testXMPExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testPSIRExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = '50686f746f73686f7020332e30003842494d04040000000' + . '000181c02190004746573741c02190003666f6f1c020000020004'; + $this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) ); + } + + public function testXMPExtractionAltAppId() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-alt.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testIPTCHashComparisionNoHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-no-hash', $res ); + } + + public function testIPTCHashComparisionBadHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-bad-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-bad-hash', $res ); + } + + public function testIPTCHashComparisionGoodHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-good-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-good-hash', $res ); + } + + public function testExifByteOrder() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'exif-user-comment.jpg' ); + $expected = 'BE'; + $this->assertEquals( $expected, $res['byteOrder'] ); + } +} diff --git a/tests/phpunit/includes/media/JpegTest.php b/tests/phpunit/includes/media/JpegTest.php new file mode 100644 index 00000000..2436e7d9 --- /dev/null +++ b/tests/phpunit/includes/media/JpegTest.php @@ -0,0 +1,54 @@ +<?php + +/** + * @group Media + * @covers JpegHandler + */ +class JpegTest extends MediaWikiMediaTestCase { + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new JpegHandler; + } + + public function testInvalidFile() { + $file = $this->dataFile( 'README', 'image/jpeg' ); + $res = $this->handler->getMetadata( $file, $this->filePath . 'README' ); + $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res ); + } + + public function testJpegMetadataExtraction() { + $file = $this->dataFile( 'test.jpg', 'image/jpeg' ); + $res = $this->handler->getMetadata( $file, $this->filePath . 'test.jpg' ); + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $expected = 'a:7:{s:16:"ImageDescription";s:9:"Test file";s:11:"XResolution";s:4:"72/1";s:11:"YResolution";s:4:"72/1";s:14:"ResolutionUnit";i:2;s:16:"YCbCrPositioning";i:1;s:15:"JPEGFileComment";a:1:{i:0;s:17:"Created with GIMP";}s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + + // Unserialize in case serialization format ever changes. + $this->assertEquals( unserialize( $expected ), unserialize( $res ) ); + } + + /** + * @covers JpegHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray() { + $file = $this->dataFile( 'test.jpg', 'image/jpeg' ); + $res = $this->handler->getCommonMetaArray( $file ); + $expected = array( + 'ImageDescription' => 'Test file', + 'XResolution' => '72/1', + 'YResolution' => '72/1', + 'ResolutionUnit' => 2, + 'YCbCrPositioning' => 1, + 'JPEGFileComment' => array( + 'Created with GIMP', + ), + ); + + $this->assertEquals( $res, $expected ); + } +} diff --git a/tests/phpunit/includes/media/MediaHandlerTest.php b/tests/phpunit/includes/media/MediaHandlerTest.php new file mode 100644 index 00000000..d8cfcc45 --- /dev/null +++ b/tests/phpunit/includes/media/MediaHandlerTest.php @@ -0,0 +1,56 @@ +<?php + +/** + * @group Media + */ +class MediaHandlerTest extends MediaWikiTestCase { + + /** + * @covers MediaHandler::fitBoxWidth + * @todo split into a dataprovider and test method + */ + public function testFitBoxWidth() { + $vals = array( + array( + 'width' => 50, + 'height' => 50, + 'tests' => array( + 50 => 50, + 17 => 17, + 18 => 18 ) ), + array( + 'width' => 366, + 'height' => 300, + 'tests' => array( + 50 => 61, + 17 => 21, + 18 => 22 ) ), + array( + 'width' => 300, + 'height' => 366, + 'tests' => array( + 50 => 41, + 17 => 14, + 18 => 15 ) ), + array( + 'width' => 100, + 'height' => 400, + 'tests' => array( + 50 => 12, + 17 => 4, + 18 => 4 ) ) ); + foreach ( $vals as $row ) { + $tests = $row['tests']; + $height = $row['height']; + $width = $row['width']; + foreach ( $tests as $max => $expected ) { + $y = round( $expected * $height / $width ); + $result = MediaHandler::fitBoxWidth( $width, $height, $max ); + $y2 = round( $result * $height / $width ); + $this->assertEquals( $expected, + $result, + "($width, $height, $max) wanted: {$expected}x$y, got: {$result}x$y2" ); + } + } + } +} diff --git a/tests/phpunit/includes/media/MediaWikiMediaTestCase.php b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php new file mode 100644 index 00000000..8f28158d --- /dev/null +++ b/tests/phpunit/includes/media/MediaWikiMediaTestCase.php @@ -0,0 +1,86 @@ +<?php +/** + * Specificly for testing Media handlers. Sets up a FSFile backend + */ +abstract class MediaWikiMediaTestCase extends MediaWikiTestCase { + + /** @var FSRepo */ + protected $repo; + /** @var FSFileBackend */ + protected $backend; + /** @var string */ + protected $filePath; + + + protected function setUp() { + parent::setUp(); + + $this->filePath = $this->getFilePath(); + $containers = array( 'data' => $this->filePath ); + if ( $this->createsThumbnails() ) { + // We need a temp directory for the thumbnails + // the container is named 'temp-thumb' because it is the + // thumb directory for a FSRepo named "temp". + $containers['temp-thumb'] = $this->getNewTempDirectory(); + } + + $this->backend = new FSFileBackend( array( + 'name' => 'localtesting', + 'wikiId' => wfWikiId(), + 'containerPaths' => $containers + ) ); + $this->repo = new FSRepo( $this->getRepoOptions() ); + } + + /** + * @return array Argument for FSRepo constructor + */ + protected function getRepoOptions() { + return array( + 'name' => 'temp', + 'url' => 'http://localhost/thumbtest', + 'backend' => $this->backend + ); + } + + /** + * The result of this method will set the file path to use, + * as well as the protected member $filePath + * + * @return string Path where files are + */ + protected function getFilePath() { + return __DIR__ . '/../../data/media/'; + } + + /** + * Will the test create thumbnails (and thus do we need to set aside + * a temporary directory for them?) + * + * Override this method if your test case creates thumbnails + * + * @return bool + */ + protected function createsThumbnails() { + return false; + } + + /** + * Utility function: Get a new file object for a file on disk but not actually in db. + * + * File must be in the path returned by getFilePath() + * @param string $name File name + * @param string $type MIME type [optional] + * @return UnregisteredLocalFile + */ + protected function dataFile( $name, $type = null ) { + if ( !$type ) { + // Autodetect by file extension for the lazy. + $magic = MimeMagic::singleton(); + $parts = explode( $name, '.' ); + $type = $magic->guessTypesForExtension( $parts[count( $parts ) - 1] ); + } + return new UnregisteredLocalFile( false, $this->repo, + "mwstore://localtesting/data/$name", $type ); + } +} diff --git a/tests/phpunit/includes/media/PNGMetadataExtractorTest.php b/tests/phpunit/includes/media/PNGMetadataExtractorTest.php new file mode 100644 index 00000000..a9eaa9e7 --- /dev/null +++ b/tests/phpunit/includes/media/PNGMetadataExtractorTest.php @@ -0,0 +1,155 @@ +<?php + +/** + * @group Media + * @covers PNGMetadataExtractor + */ +class PNGMetadataExtractorTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + $this->filePath = __DIR__ . '/../../data/media/'; + } + + /** + * Tests zTXt tag (compressed textual metadata) + */ + public function testPngNativetZtxt() { + $this->checkPHPExtension( 'zlib' ); + + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + $expected = "foo bar baz foo foo foo foof foo foo foo foo"; + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'Make', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['Make'] ); + + $this->assertEquals( $expected, $meta['Make']['x-default'] ); + } + + /** + * Test tEXt tag (Uncompressed textual metadata) + */ + public function testPngNativeText() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + $expected = "Some long image desc"; + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'ImageDescription', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['ImageDescription'] ); + $this->assertArrayHasKey( '_type', $meta['ImageDescription'] ); + + $this->assertEquals( $expected, $meta['ImageDescription']['x-default'] ); + } + + /** + * tEXt tags must be encoded iso-8859-1 (vs iTXt which are utf-8) + * Make sure non-ascii characters get converted properly + */ + public function testPngNativeTextNonAscii() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + // Note the Copyright symbol here is a utf-8 one + // (aka \xC2\xA9) where in the file its iso-8859-1 + // encoded as just \xA9. + $expected = "© 2010 Bawolff"; + + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + $this->assertArrayHasKey( 'Copyright', $meta ); + $this->assertArrayHasKey( 'x-default', $meta['Copyright'] ); + + $this->assertEquals( $expected, $meta['Copyright']['x-default'] ); + } + + /** + * Test extraction of pHYs tags, which can tell what the + * actual resolution of the image is (aka in dots per meter). + */ + /* + public function testPngPhysTag() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertArrayHasKey( 'text', $meta ); + $meta = $meta['text']; + + $this->assertEquals( '2835/100', $meta['XResolution'] ); + $this->assertEquals( '2835/100', $meta['YResolution'] ); + $this->assertEquals( 3, $meta['ResolutionUnit'] ); // 3 = cm + } + */ + + /** + * Given a normal static PNG, check the animation metadata returned. + */ + public function testStaticPngAnimationMetadata() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 0, $meta['frameCount'] ); + $this->assertEquals( 1, $meta['loopCount'] ); + $this->assertEquals( 0, $meta['duration'] ); + } + + /** + * Given an animated APNG image file + * check it gets animated metadata right. + */ + public function testApngAnimationMetadata() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Animated_PNG_example_bouncing_beach_ball.png' ); + + $this->assertEquals( 20, $meta['frameCount'] ); + // Note loop count of 0 = infinity + $this->assertEquals( 0, $meta['loopCount'] ); + $this->assertEquals( 1.5, $meta['duration'], '', 0.00001 ); + } + + public function testPngBitDepth8() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 8, $meta['bitDepth'] ); + } + + public function testPngBitDepth1() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + '1bit-png.png' ); + $this->assertEquals( 1, $meta['bitDepth'] ); + } + + public function testPngIndexColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'Png-native-test.png' ); + + $this->assertEquals( 'index-coloured', $meta['colorType'] ); + } + + public function testPngRgbColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'rgb-png.png' ); + $this->assertEquals( 'truecolour-alpha', $meta['colorType'] ); + } + + public function testPngRgbNoAlphaColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'rgb-na-png.png' ); + $this->assertEquals( 'truecolour', $meta['colorType'] ); + } + + public function testPngGreyscaleColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'greyscale-png.png' ); + $this->assertEquals( 'greyscale-alpha', $meta['colorType'] ); + } + + public function testPngGreyscaleNoAlphaColour() { + $meta = PNGMetadataExtractor::getMetadata( $this->filePath . + 'greyscale-na-png.png' ); + $this->assertEquals( 'greyscale', $meta['colorType'] ); + } +} diff --git a/tests/phpunit/includes/media/PNGTest.php b/tests/phpunit/includes/media/PNGTest.php new file mode 100644 index 00000000..36872a75 --- /dev/null +++ b/tests/phpunit/includes/media/PNGTest.php @@ -0,0 +1,131 @@ +<?php + +/** + * @group Media + */ +class PNGHandlerTest extends MediaWikiMediaTestCase { + + /** @var PNGHandler */ + protected $handler; + + protected function setUp() { + parent::setUp(); + $this->handler = new PNGHandler(); + } + + /** + * @covers PNGHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . '/README' ); + $this->assertEquals( PNGHandler::BROKEN_FILE, $res ); + } + + /** + * @param string $filename Basename of the file to check + * @param bool $expected Expected result. + * @dataProvider provideIsAnimated + * @covers PNGHandler::isAnimatedImage + */ + public function testIsAnimanted( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->isAnimatedImage( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsAnimated() { + return array( + array( 'Animated_PNG_example_bouncing_beach_ball.png', true ), + array( '1bit-png.png', false ), + ); + } + + /** + * @param string $filename + * @param int $expected Total image area + * @dataProvider provideGetImageArea + * @covers PNGHandler::getImageArea + */ + public function testGetImageArea( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getImageArea( $file, $file->getWidth(), $file->getHeight() ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetImageArea() { + return array( + array( '1bit-png.png', 2500 ), + array( 'greyscale-png.png', 2500 ), + array( 'Png-native-test.png', 126000 ), + array( 'Animated_PNG_example_bouncing_beach_ball.png', 10000 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of PNGHandler + * @dataProvider provideIsMetadataValid + * @covers PNGHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( PNGHandler::BROKEN_FILE, PNGHandler::METADATA_GOOD ), + array( '', PNGHandler::METADATA_BAD ), + array( null, PNGHandler::METADATA_BAD ), + array( 'Something invalid!', PNGHandler::METADATA_BAD ), + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}', PNGHandler::METADATA_GOOD ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers PNGHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); +// $this->assertEquals( unserialize( $expected ), unserialize( $actual ) ); + $this->assertEquals( ( $expected ), ( $actual ) ); + } + + public static function provideGetMetadata() { + return array( + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + array( 'rgb-na-png.png', 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:8;s:9:"colorType";s:10:"truecolour";s:8:"metadata";a:1:{s:15:"_MW_PNG_VERSION";i:1;}}' ), + array( 'xmp.png', 'a:6:{s:10:"frameCount";i:0;s:9:"loopCount";i:1;s:8:"duration";d:0;s:8:"bitDepth";i:1;s:9:"colorType";s:14:"index-coloured";s:8:"metadata";a:2:{s:12:"SerialNumber";s:9:"123456789";s:15:"_MW_PNG_VERSION";i:1;}}' ), + // @codingStandardsIgnoreEnd + ); + } + + /** + * @param string $filename + * @param array $expected Expected standard metadata + * @dataProvider provideGetIndependentMetaArray + * @covers PNGHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getCommonMetaArray( $file ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetIndependentMetaArray() { + return array( + array( 'rgb-na-png.png', array() ), + array( 'xmp.png', + array( + 'SerialNumber' => '123456789', + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php new file mode 100644 index 00000000..ab33d1c2 --- /dev/null +++ b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php @@ -0,0 +1,160 @@ +<?php + +/** + * @group Media + * @covers SVGMetadataExtractor + */ +class SVGMetadataExtractorTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + AutoLoader::loadClass( 'SVGMetadataExtractorTest' ); + } + + /** + * @dataProvider provideSvgFiles + */ + public function testGetMetadata( $infile, $expected ) { + $this->assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgFilesWithXMLMetadata + */ + public function testGetXMLMetadata( $infile, $expected ) { + $r = new XMLReader(); + if ( !method_exists( $r, 'readInnerXML' ) ) { + $this->markTestSkipped( 'XMLReader::readInnerXML() does not exist (libxml >2.6.20 needed).' ); + + return; + } + $this->assertMetadata( $infile, $expected ); + } + + function assertMetadata( $infile, $expected ) { + try { + $data = SVGMetadataExtractor::getMetadata( $infile ); + $this->assertEquals( $expected, $data, 'SVG metadata extraction test' ); + } catch ( MWException $e ) { + if ( $expected === false ) { + $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' ); + } else { + throw $e; + } + } + } + + public static function provideSvgFiles() { + $base = __DIR__ . '/../../data/media'; + + return array( + array( + "$base/Wikimedia-logo.svg", + array( + 'width' => 1024, + 'height' => 1024, + 'originalWidth' => '1024', + 'originalHeight' => '1024', + 'translations' => array(), + ) + ), + array( + "$base/QA_icon.svg", + array( + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60', + 'originalHeight' => '60', + 'translations' => array(), + ) + ), + array( + "$base/Gtk-media-play-ltr.svg", + array( + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60.0000000', + 'originalHeight' => '60.0000000', + 'translations' => array(), + ) + ), + array( + "$base/Toll_Texas_1.svg", + // This file triggered bug 31719, needs entity expansion in the xmlns checks + array( + 'width' => 385, + 'height' => 385, + 'originalWidth' => '385', + 'originalHeight' => '385.0004883', + 'translations' => array(), + ) + ), + array( + "$base/Tux.svg", + array( + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'title' => 'Tux', + 'translations' => array(), + 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ) + ), + array( + "$base/Speech_bubbles.svg", + array( + 'width' => 627, + 'height' => 461, + 'originalWidth' => '17.7cm', + 'originalHeight' => '13cm', + 'translations' => array( + 'de' => SVGReader::LANG_FULL_MATCH, + 'fr' => SVGReader::LANG_FULL_MATCH, + 'nl' => SVGReader::LANG_FULL_MATCH, + 'tlh-ca' => SVGReader::LANG_FULL_MATCH, + 'tlh' => SVGReader::LANG_PREFIX_MATCH + ), + ) + ), + array( + "$base/Soccer_ball_animated.svg", + array( + 'width' => 150, + 'height' => 150, + 'originalWidth' => '150', + 'originalHeight' => '150', + 'animated' => true, + 'translations' => array() + ), + ), + ); + } + + public static function provideSvgFilesWithXMLMetadata() { + $base = __DIR__ . '/../../data/media'; + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $metadata = '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <ns4:Work xmlns:ns4="http://creativecommons.org/ns#" rdf:about=""> + <ns5:format xmlns:ns5="http://purl.org/dc/elements/1.1/">image/svg+xml</ns5:format> + <ns5:type xmlns:ns5="http://purl.org/dc/elements/1.1/" rdf:resource="http://purl.org/dc/dcmitype/StillImage"/> + </ns4:Work> + </rdf:RDF>'; + // @codingStandardsIgnoreEnd + + $metadata = str_replace( "\r", '', $metadata ); // Windows compat + return array( + array( + "$base/US_states_by_total_state_tax_revenue.svg", + array( + 'height' => 593, + 'metadata' => $metadata, + 'width' => 959, + 'originalWidth' => '958.69', + 'originalHeight' => '592.78998', + 'translations' => array(), + ) + ), + ); + } +} diff --git a/tests/phpunit/includes/media/SVGTest.php b/tests/phpunit/includes/media/SVGTest.php new file mode 100644 index 00000000..8f7a0d69 --- /dev/null +++ b/tests/phpunit/includes/media/SVGTest.php @@ -0,0 +1,41 @@ +<?php + +/** + * @group Media + */ +class SvgTest extends MediaWikiMediaTestCase { + + protected function setUp() { + parent::setUp(); + + $this->filePath = __DIR__ . '/../../data/media/'; + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->handler = new SvgHandler; + } + + /** + * @param string $filename + * @param array $expected The expected independent metadata + * @dataProvider providerGetIndependentMetaArray + * @covers SvgHandler::getCommonMetaArray + */ + public function testGetIndependentMetaArray( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/svg+xml' ); + $res = $this->handler->getCommonMetaArray( $file ); + + $this->assertEquals( $res, $expected ); + } + + public static function providerGetIndependentMetaArray() { + return array( + array( 'Tux.svg', array( + 'ObjectName' => 'Tux', + 'ImageDescription' => + 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ) ), + array( 'Wikimedia-logo.svg', array() ) + ); + } +} diff --git a/tests/phpunit/includes/media/TiffTest.php b/tests/phpunit/includes/media/TiffTest.php new file mode 100644 index 00000000..d1148202 --- /dev/null +++ b/tests/phpunit/includes/media/TiffTest.php @@ -0,0 +1,45 @@ +<?php + +/** + * @group Media + */ +class TiffTest extends MediaWikiTestCase { + + /** @var TiffHandler */ + protected $handler; + /** @var string */ + protected $filePath; + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); + + $this->setMwGlobals( 'wgShowEXIF', true ); + + $this->filePath = __DIR__ . '/../../data/media/'; + $this->handler = new TiffHandler; + } + + /** + * @covers TiffHandler::getMetadata + */ + public function testInvalidFile() { + $res = $this->handler->getMetadata( null, $this->filePath . 'README' ); + $this->assertEquals( ExifBitmapHandler::BROKEN_FILE, $res ); + } + + /** + * @covers TiffHandler::getMetadata + */ + public function testTiffMetadataExtraction() { + $res = $this->handler->getMetadata( null, $this->filePath . 'test.tiff' ); + + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + $expected = 'a:16:{s:10:"ImageWidth";i:20;s:11:"ImageLength";i:20;s:13:"BitsPerSample";a:3:{i:0;i:8;i:1;i:8;i:2;i:8;}s:11:"Compression";i:5;s:25:"PhotometricInterpretation";i:2;s:16:"ImageDescription";s:17:"Created with GIMP";s:12:"StripOffsets";i:8;s:11:"Orientation";i:1;s:15:"SamplesPerPixel";i:3;s:12:"RowsPerStrip";i:64;s:15:"StripByteCounts";i:238;s:11:"XResolution";s:19:"1207959552/16777216";s:11:"YResolution";s:19:"1207959552/16777216";s:19:"PlanarConfiguration";i:1;s:14:"ResolutionUnit";i:2;s:22:"MEDIAWIKI_EXIF_VERSION";i:2;}'; + // @codingStandardsIgnoreEnd + + // Re-unserialize in case there are subtle differences between how versions + // of php serialize stuff. + $this->assertEquals( unserialize( $expected ), unserialize( $res ) ); + } +} diff --git a/tests/phpunit/includes/media/XCFTest.php b/tests/phpunit/includes/media/XCFTest.php new file mode 100644 index 00000000..5b2de151 --- /dev/null +++ b/tests/phpunit/includes/media/XCFTest.php @@ -0,0 +1,78 @@ +<?php + +/** + * @group Media + */ +class XCFHandlerTest extends MediaWikiMediaTestCase { + + /** @var XCFHandler */ + protected $handler; + + protected function setUp() { + parent::setUp(); + $this->handler = new XCFHandler(); + } + + + /** + * @param string $filename + * @param int $expectedWidth Width + * @param int $expectedHeight Height + * @dataProvider provideGetImageSize + * @covers XCFHandler::getImageSize + */ + public function testGetImageSize( $filename, $expectedWidth, $expectedHeight ) { + $file = $this->dataFile( $filename, 'image/x-xcf' ); + $actual = $this->handler->getImageSize( $file, $file->getLocalRefPath() ); + $this->assertEquals( $expectedWidth, $actual[0] ); + $this->assertEquals( $expectedHeight, $actual[1] ); + } + + public static function provideGetImageSize() { + return array( + array( '80x60-2layers.xcf', 80, 60 ), + array( '80x60-RGB.xcf', 80, 60 ), + array( '80x60-Greyscale.xcf', 80, 60 ), + ); + } + + /** + * @param string $metadata Serialized metadata + * @param int $expected One of the class constants of XCFHandler + * @dataProvider provideIsMetadataValid + * @covers XCFHandler::isMetadataValid + */ + public function testIsMetadataValid( $metadata, $expected ) { + $actual = $this->handler->isMetadataValid( null, $metadata ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideIsMetadataValid() { + return array( + array( '', XCFHandler::METADATA_BAD ), + array( serialize( array( 'error' => true ) ), XCFHandler::METADATA_GOOD ), + array( false, XCFHandler::METADATA_BAD ), + array( serialize( array( 'colorType' => 'greyscale-alpha' ) ), XCFHandler::METADATA_GOOD ), + ); + } + + /** + * @param string $filename + * @param string $expected Serialized array + * @dataProvider provideGetMetadata + * @covers XCFHandler::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $file = $this->dataFile( $filename, 'image/png' ); + $actual = $this->handler->getMetadata( $file, "$this->filePath/$filename" ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + return array( + array( '80x60-2layers.xcf', 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}' ), + array( '80x60-RGB.xcf', 'a:1:{s:9:"colorType";s:16:"truecolour-alpha";}' ), + array( '80x60-Greyscale.xcf', 'a:1:{s:9:"colorType";s:15:"greyscale-alpha";}' ), + ); + } +} diff --git a/tests/phpunit/includes/media/XMPTest.php b/tests/phpunit/includes/media/XMPTest.php new file mode 100644 index 00000000..6758e94c --- /dev/null +++ b/tests/phpunit/includes/media/XMPTest.php @@ -0,0 +1,223 @@ +<?php + +/** + * @group Media + * @covers XMPReader + */ +class XMPTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + $this->checkPHPExtension( 'exif' ); # Requires libxml to do XMP parsing + } + + /** + * Put XMP in, compare what comes out... + * + * @param string $xmp The actual xml data. + * @param array $expected Expected result of parsing the xmp. + * @param string $info Short sentence on what's being tested. + * + * @throws Exception + * @dataProvider provideXMPParse + * + * @covers XMPReader::parse + */ + public function testXMPParse( $xmp, $expected, $info ) { + if ( !is_string( $xmp ) || !is_array( $expected ) ) { + throw new Exception( "Invalid data provided to " . __METHOD__ ); + } + $reader = new XMPReader; + $reader->parse( $xmp ); + $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 ); + } + + public static function provideXMPParse() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $data = array(); + + // $xmpFiles format: array of arrays with first arg file base name, + // with the actual file having .xmp on the end for the xmp + // and .result.php on the end for a php file containing the result + // array. Second argument is some info on what's being tested. + $xmpFiles = array( + array( '1', 'parseType=Resource test' ), + array( '2', 'Structure with mixed attribute and element props' ), + array( '3', 'Extra qualifiers (that should be ignored)' ), + array( '3-invalid', 'Test ignoring qualifiers that look like normal props' ), + array( '4', 'Flash as qualifier' ), + array( '5', 'Flash as qualifier 2' ), + array( '6', 'Multiple rdf:Description' ), + array( '7', 'Generic test of several property types' ), + array( 'flash', 'Test of Flash property' ), + array( 'invalid-child-not-struct', 'Test child props not in struct or ignored' ), + array( 'no-recognized-props', 'Test namespace and no recognized props' ), + array( 'no-namespace', 'Test non-namespaced attributes are ignored' ), + array( 'bag-for-seq', "Allow bag's instead of seq's. (bug 27105)" ), + array( 'utf16BE', 'UTF-16BE encoding' ), + array( 'utf16LE', 'UTF-16LE encoding' ), + array( 'utf32BE', 'UTF-32BE encoding' ), + array( 'utf32LE', 'UTF-32LE encoding' ), + array( 'xmpExt', 'Extended XMP missing second part' ), + array( 'gps', 'Handling of exif GPS parameters in XMP' ), + ); + + $xmpFiles[] = array( 'doctype-included', 'XMP includes doctype' ); + + foreach ( $xmpFiles as $file ) { + $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' ); + // I'm not sure if this is the best way to handle getting the + // result array, but it seems kind of big to put directly in the test + // file. + $result = null; + include $xmpPath . $file[0] . '.result.php'; + $data[] = array( $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ); + } + + return $data; + } + + /** Test ExtendedXMP block support. (Used when the XMP has to be split + * over multiple jpeg segments, due to 64k size limit on jpeg segments. + * + * @todo This is based on what the standard says. Need to find a real + * world example file to double check the support for this is right. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMP() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + 'FNumber' => '2/10', + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * This test has an extended XMP block with a wrong guid (md5sum) + * and thus should only return the StandardXMP, not the ExtendedXMP. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPWithWrongGUID() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit. + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 0 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * Have a high offset to simulate a missing packet, + * which should cause it to ignore the ExtendedXMP packet. + * + * @covers XMPReader::parseExtended + */ + public function testExtendedXMPMissingPacket() { + $xmpPath = __DIR__ . '/../../data/xmp/'; + $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' ); + $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' ); + + $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp + $length = pack( 'N', strlen( $extendedXMP ) ); + $offset = pack( 'N', 2048 ); + $extendedPacket = $md5sum . $length . $offset . $extendedXMP; + + $reader = new XMPReader(); + $reader->parse( $standardXMP ); + $reader->parseExtended( $extendedPacket ); + $actual = $reader->getResults(); + + $expected = array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => 9, + ) + ); + + $this->assertEquals( $expected, $actual ); + } + + /** + * Test for multi-section, hostile XML + * @covers checkParseSafety + */ + public function testCheckParseSafety() { + + // Test for detection + $xmpPath = __DIR__ . '/../../data/xmp/'; + $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' ); + $this->assertEquals( + array(), + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + fclose( $file ); + unset( $reader ); + + // Test for false positives + $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' ); + $valid = false; + $reader = new XMPReader(); + do { + $chunk = fread( $file, 10 ); + $valid = $reader->parse( $chunk, feof( $file ) ); + } while ( !feof( $file ) ); + $this->assertTrue( + $valid, + 'Check for false-positive detecting doctype in fragmented XML' + ); + $this->assertEquals( + array( + 'xmp-exif' => array( + 'DigitalZoomRatio' => '0/10', + 'Flash' => '9' + ) + ), + $reader->getResults(), + 'Check that doctype is detected in fragmented XML' + ); + } +} diff --git a/tests/phpunit/includes/media/XMPValidateTest.php b/tests/phpunit/includes/media/XMPValidateTest.php new file mode 100644 index 00000000..ebec8f6c --- /dev/null +++ b/tests/phpunit/includes/media/XMPValidateTest.php @@ -0,0 +1,50 @@ +<?php + +/** + * @group Media + */ +class XMPValidateTest extends MediaWikiTestCase { + + /** + * @dataProvider provideDates + * @covers XMPValidate::validateDate + */ + public function testValidateDate( $value, $expected ) { + // The method should modify $value. + XMPValidate::validateDate( array(), $value, true ); + $this->assertEquals( $expected, $value ); + } + + public static function provideDates() { + /* For reference valid date formats are: + * YYYY + * YYYY-MM + * YYYY-MM-DD + * YYYY-MM-DDThh:mmTZD + * YYYY-MM-DDThh:mm:ssTZD + * YYYY-MM-DDThh:mm:ss.sTZD + * (Time zone is optional) + */ + return array( + array( '1992', '1992' ), + array( '1992-04', '1992:04' ), + array( '1992-02-01', '1992:02:01' ), + array( '2011-09-29', '2011:09:29' ), + array( '1982-12-15T20:12', '1982:12:15 20:12' ), + array( '1982-12-15T20:12Z', '1982:12:15 20:12' ), + array( '1982-12-15T20:12+02:30', '1982:12:15 22:42' ), + array( '1982-12-15T01:12-02:30', '1982:12:14 22:42' ), + array( '1982-12-15T20:12:11', '1982:12:15 20:12:11' ), + array( '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ), + array( '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ), + array( '2045-12-15T20:12:11', '2045:12:15 20:12:11' ), + array( '1867-06-01T15:00:00', '1867:06:01 15:00:00' ), + /* some invalid ones */ + array( '2001--12', null ), + array( '2001-5-12', null ), + array( '2001-5-12TZ', null ), + array( '2001-05-12T15', null ), + array( '2001-12T15:13', null ), + ); + } +} diff --git a/tests/phpunit/includes/normal/CleanUpTest.php b/tests/phpunit/includes/normal/CleanUpTest.php new file mode 100644 index 00000000..f4b469b8 --- /dev/null +++ b/tests/phpunit/includes/normal/CleanUpTest.php @@ -0,0 +1,409 @@ +<?php +/** + * Tests for UtfNormal::cleanUp() function. + * + * Copyright © 2004 Brion Vibber <brion@pobox.com> + * https://www.mediawiki.org/ + * + * 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 + */ + +/** + * Additional tests for UtfNormal::cleanUp() function, inclusion + * regression checks for known problems. + * Requires PHPUnit. + * + * @ingroup UtfNormal + * @group Large + * + * @todo covers tags, will be UtfNormal::cleanUp once the below is resolved + * @todo split me into test methods and providers per the below comment + * + * We ignore code coverage for this test suite until they are rewritten + * to use data providers (bug 46561). + * @codeCoverageIgnore + */ +class CleanUpTest extends MediaWikiTestCase { + /** @todo document */ + public function testAscii() { + $text = 'This is plain ASCII text.'; + $this->assertEquals( $text, UtfNormal::cleanUp( $text ) ); + } + + /** @todo document */ + public function testNull() { + $text = "a \x00 null"; + $expect = "a \xef\xbf\xbd null"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testLatin() { + $text = "L'\xc3\xa9cole"; + $this->assertEquals( $text, UtfNormal::cleanUp( $text ) ); + } + + /** @todo document */ + public function testLatinNormal() { + $text = "L'e\xcc\x81cole"; + $expect = "L'\xc3\xa9cole"; + $this->assertEquals( $expect, UtfNormal::cleanUp( $text ) ); + } + + /** + * This test is *very* expensive! + * @todo document + */ + function XtestAllChars() { + $rep = UTF8_REPLACEMENT; + for ( $i = 0x0; $i < UNICODE_MAX; $i++ ) { + $char = codepointToUtf8( $i ); + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%04X", $i ); + + if ( $i % 0x1000 == 0 ) { + echo "U+$x\n"; + } + + if ( $i == 0x0009 || + $i == 0x000a || + $i == 0x000d || + ( $i > 0x001f && $i < UNICODE_SURROGATE_FIRST ) || + ( $i > UNICODE_SURROGATE_LAST && $i < 0xfffe ) || + ( $i > 0xffff && $i <= UNICODE_MAX ) + ) { + if ( isset( UtfNormal::$utfCanonicalComp[$char] ) + || isset( UtfNormal::$utfCanonicalDecomp[$char] ) + ) { + $comp = UtfNormal::NFC( $char ); + $this->assertEquals( + bin2hex( $comp ), + bin2hex( $clean ), + "U+$x should be decomposed" ); + } else { + $this->assertEquals( + bin2hex( $char ), + bin2hex( $clean ), + "U+$x should be intact" ); + } + } else { + $this->assertEquals( bin2hex( $rep ), bin2hex( $clean ), $x ); + } + } + } + + /** @todo document */ + public static function provideAllBytes() { + return array( + array( '', '' ), + array( 'x', '' ), + array( '', 'x' ), + array( 'x', 'x' ), + ); + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testBytes( $head, $tail ) { + for ( $i = 0x0; $i < 256; $i++ ) { + $char = $head . chr( $i ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X", $i ); + + if ( $i == 0x0009 || + $i == 0x000a || + $i == 0x000d || + ( $i > 0x001f && $i < 0x80 ) + ) { + $this->assertEquals( + bin2hex( $char ), + bin2hex( $clean ), + "ASCII byte $x should be intact" ); + if ( $char != $clean ) { + return; + } + } else { + $norm = $head . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden byte $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } + } + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testDoubleBytes( $head, $tail ) { + for ( $first = 0xc0; $first < 0x100; $first += 2 ) { + for ( $second = 0x80; $second < 0x100; $second += 2 ) { + $char = $head . chr( $first ) . chr( $second ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X,%02X", $first, $second ); + if ( $first > 0xc1 && + $first < 0xe0 && + $second < 0xc0 + ) { + $norm = UtfNormal::NFC( $char ); + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Pair $x should be intact" ); + if ( $norm != $clean ) { + return; + } + } elseif ( $first > 0xfd || $second > 0xbf ) { + # fe and ff are not legal head bytes -- expect two replacement chars + $norm = $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden pair $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } else { + $norm = $head . UTF8_REPLACEMENT . $tail; + $this->assertEquals( + bin2hex( $norm ), + bin2hex( $clean ), + "Forbidden pair $x should be rejected" ); + if ( $norm != $clean ) { + return; + } + } + } + } + } + + /** + * @dataProvider provideAllBytes + * @todo document + */ + function testTripleBytes( $head, $tail ) { + for ( $first = 0xc0; $first < 0x100; $first += 2 ) { + for ( $second = 0x80; $second < 0x100; $second += 2 ) { + #for( $third = 0x80; $third < 0x100; $third++ ) { + for ( $third = 0x80; $third < 0x81; $third++ ) { + $char = $head . chr( $first ) . chr( $second ) . chr( $third ) . $tail; + $clean = UtfNormal::cleanUp( $char ); + $x = sprintf( "%02X,%02X,%02X", $first, $second, $third ); + + if ( $first >= 0xe0 && + $first < 0xf0 && + $second < 0xc0 && + $third < 0xc0 + ) { + if ( $first == 0xe0 && $second < 0xa0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Overlong triplet $x should be rejected" ); + } elseif ( $first == 0xed && + ( chr( $first ) . chr( $second ) . chr( $third ) ) >= UTF8_SURROGATE_FIRST + ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Surrogate triplet $x should be rejected" ); + } else { + $this->assertEquals( + bin2hex( UtfNormal::NFC( $char ) ), + bin2hex( $clean ), + "Triplet $x should be intact" ); + } + } elseif ( $first > 0xc1 && $first < 0xe0 && $second < 0xc0 ) { + $this->assertEquals( + bin2hex( UtfNormal::NFC( $head . chr( $first ) . + chr( $second ) ) . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Valid 2-byte $x + broken tail" ); + } elseif ( $second > 0xc1 && $second < 0xe0 && $third < 0xc0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . + UtfNormal::NFC( chr( $second ) . chr( $third ) . $tail ) ), + bin2hex( $clean ), + "Broken head + valid 2-byte $x" ); + } elseif ( ( $first > 0xfd || $second > 0xfd ) && + ( ( $second > 0xbf && $third > 0xbf ) || + ( $second < 0xc0 && $third < 0xc0 ) || + ( $second > 0xfd ) || + ( $third > 0xfd ) ) + ) { + # fe and ff are not legal head bytes -- expect three replacement chars + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } elseif ( $first > 0xc2 && $second < 0xc0 && $third < 0xc0 ) { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } else { + $this->assertEquals( + bin2hex( $head . UTF8_REPLACEMENT . UTF8_REPLACEMENT . $tail ), + bin2hex( $clean ), + "Forbidden triplet $x should be rejected" ); + } + } + } + } + } + + /** @todo document */ + public function testChunkRegression() { + # Check for regression against a chunking bug + $text = "\x46\x55\xb8" . + "\xdc\x96" . + "\xee" . + "\xe7" . + "\x44" . + "\xaa" . + "\x2f\x25"; + $expect = "\x46\x55\xef\xbf\xbd" . + "\xdc\x96" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x44" . + "\xef\xbf\xbd" . + "\x2f\x25"; + + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testInterposeRegression() { + $text = "\x4e\x30" . + "\xb1" . # bad tail + "\x3a" . + "\x92" . # bad tail + "\x62\x3a" . + "\x84" . # bad tail + "\x43" . + "\xc6" . # bad head + "\x3f" . + "\x92" . # bad tail + "\xad" . # bad tail + "\x7d" . + "\xd9\x95"; + + $expect = "\x4e\x30" . + "\xef\xbf\xbd" . + "\x3a" . + "\xef\xbf\xbd" . + "\x62\x3a" . + "\xef\xbf\xbd" . + "\x43" . + "\xef\xbf\xbd" . + "\x3f" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x7d" . + "\xd9\x95"; + + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testOverlongRegression() { + $text = "\x67" . + "\x1a" . # forbidden ascii + "\xea" . # bad head + "\xc1\xa6" . # overlong sequence + "\xad" . # bad tail + "\x1c" . # forbidden ascii + "\xb0" . # bad tail + "\x3c" . + "\x9e"; # bad tail + $expect = "\x67" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x3c" . + "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testSurrogateRegression() { + $text = "\xed\xb4\x96" . # surrogate 0xDD16 + "\x83" . # bad tail + "\xb4" . # bad tail + "\xac"; # bad head + $expect = "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testBomRegression() { + $text = "\xef\xbf\xbe" . # U+FFFE, illegal char + "\xb2" . # bad tail + "\xef" . # bad head + "\x59"; + $expect = "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\xef\xbf\xbd" . + "\x59"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testForbiddenRegression() { + $text = "\xef\xbf\xbf"; # U+FFFF, illegal char + $expect = "\xef\xbf\xbd"; + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } + + /** @todo document */ + public function testHangulRegression() { + $text = "\xed\x9c\xaf" . # Hangul char + "\xe1\x87\x81"; # followed by another final jamo + $expect = $text; # Should *not* change. + $this->assertEquals( + bin2hex( $expect ), + bin2hex( UtfNormal::cleanUp( $text ) ) ); + } +} diff --git a/tests/phpunit/includes/objectcache/BagOStuffTest.php b/tests/phpunit/includes/objectcache/BagOStuffTest.php new file mode 100644 index 00000000..987b6e64 --- /dev/null +++ b/tests/phpunit/includes/objectcache/BagOStuffTest.php @@ -0,0 +1,147 @@ +<?php +/** + * This class will test BagOStuff. + * + * @author Matthias Mullie <mmullie@wikimedia.org> + */ +class BagOStuffTest extends MediaWikiTestCase { + private $cache; + + protected function setUp() { + parent::setUp(); + + // type defined through parameter + if ( $this->getCliArg( 'use-bagostuff' ) ) { + $name = $this->getCliArg( 'use-bagostuff' ); + + $this->cache = ObjectCache::newFromId( $name ); + } else { + // no type defined - use simple hash + $this->cache = new HashBagOStuff; + } + + $this->cache->delete( wfMemcKey( 'test' ) ); + } + + public function testMerge() { + $key = wfMemcKey( 'test' ); + + $usleep = 0; + + /** + * Callback method: append "merged" to whatever is in cache. + * + * @param BagOStuff $cache + * @param string $key + * @param int $existingValue + * @use int $usleep + * @return int + */ + $callback = function ( BagOStuff $cache, $key, $existingValue ) use ( &$usleep ) { + // let's pretend this is an expensive callback to test concurrent merge attempts + usleep( $usleep ); + + if ( $existingValue === false ) { + return 'merged'; + } + + return $existingValue . 'merged'; + }; + + // merge on non-existing value + $merged = $this->cache->merge( $key, $callback, 0 ); + $this->assertTrue( $merged ); + $this->assertEquals( $this->cache->get( $key ), 'merged' ); + + // merge on existing value + $merged = $this->cache->merge( $key, $callback, 0 ); + $this->assertTrue( $merged ); + $this->assertEquals( $this->cache->get( $key ), 'mergedmerged' ); + + /* + * Test concurrent merges by forking this process, if: + * - not manually called with --use-bagostuff + * - pcntl_fork is supported by the system + * - cache type will correctly support calls over forks + */ + $fork = (bool)$this->getCliArg( 'use-bagostuff' ); + $fork &= function_exists( 'pcntl_fork' ); + $fork &= !$this->cache instanceof HashBagOStuff; + $fork &= !$this->cache instanceof EmptyBagOStuff; + $fork &= !$this->cache instanceof MultiWriteBagOStuff; + if ( $fork ) { + // callback should take awhile now so that we can test concurrent merge attempts + $pid = pcntl_fork(); + if ( $pid == -1 ) { + // can't fork, ignore this test... + } elseif ( $pid ) { + // wait a little, making sure that the child process is calling merge + usleep( 3000 ); + + // attempt a merge - this should fail + $merged = $this->cache->merge( $key, $callback, 0, 1 ); + + // merge has failed because child process was merging (and we only attempted once) + $this->assertFalse( $merged ); + + // make sure the child's merge is completed and verify + usleep( 3000 ); + $this->assertEquals( $this->cache->get( $key ), 'mergedmergedmerged' ); + } else { + $this->cache->merge( $key, $callback, 0, 1 ); + + // Note: I'm not even going to check if the merge worked, I'll + // compare values in the parent process to test if this merge worked. + // I'm just going to exit this child process, since I don't want the + // child to output any test results (would be rather confusing to + // have test output twice) + exit; + } + } + } + + public function testAdd() { + $key = wfMemcKey( 'test' ); + $this->assertTrue( $this->cache->add( $key, 'test' ) ); + } + + public function testGet() { + $value = array( 'this' => 'is', 'a' => 'test' ); + + $key = wfMemcKey( 'test' ); + $this->cache->add( $key, $value ); + $this->assertEquals( $this->cache->get( $key ), $value ); + } + + /** + * @covers BagOStuff::incr + */ + public function testIncr() { + $key = wfMemcKey( 'test' ); + $this->cache->add( $key, 0 ); + $this->cache->incr( $key ); + $expectedValue = 1; + $actualValue = $this->cache->get( $key ); + $this->assertEquals( $expectedValue, $actualValue, 'Value should be 1 after incrementing' ); + } + + public function testGetMulti() { + $value1 = array( 'this' => 'is', 'a' => 'test' ); + $value2 = array( 'this' => 'is', 'another' => 'test' ); + + $key1 = wfMemcKey( 'test1' ); + $key2 = wfMemcKey( 'test2' ); + + $this->cache->add( $key1, $value1 ); + $this->cache->add( $key2, $value2 ); + + $this->assertEquals( + $this->cache->getMulti( array( $key1, $key2 ) ), + array( $key1 => $value1, $key2 => $value2 ) + ); + + // cleanup + $this->cache->delete( $key1 ); + $this->cache->delete( $key2 ); + } +} diff --git a/tests/phpunit/includes/parser/MagicVariableTest.php b/tests/phpunit/includes/parser/MagicVariableTest.php new file mode 100644 index 00000000..17226113 --- /dev/null +++ b/tests/phpunit/includes/parser/MagicVariableTest.php @@ -0,0 +1,229 @@ +<?php +/** + * This file is intended to test magic variables in the parser + * It was inspired by Raymond & Matěj Grabovský commenting about r66200 + * + * As of february 2011, it only tests some revisions and date related + * magic variables. + * + * @author Antoine Musso + * @copyright Copyright © 2011, Antoine Musso + * @file + * @todo covers tags + */ + +class MagicVariableTest extends MediaWikiTestCase { + /** + * @var Parser + */ + private $testParser = null; + + /** + * An array of magicword returned as type integer by the parser + * They are usually returned as a string for i18n since we support + * persan numbers for example, but some magic explicitly return + * them as integer. + * @see MagicVariableTest::assertMagic() + */ + private $expectedAsInteger = array( + 'revisionday', + 'revisionmonth1', + ); + + /** setup a basic parser object */ + protected function setUp() { + parent::setUp(); + + $contLang = Language::factory( 'en' ); + $this->setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => $contLang, + ) ); + + $this->testParser = new Parser(); + $this->testParser->Options( ParserOptions::newFromUserAndLang( new User, $contLang ) ); + + # initialize parser output + $this->testParser->clearState(); + + # Needs a title to do magic word stuff + $title = Title::newFromText( 'Tests' ); + # Else it needs a db connection just to check if it's a redirect + # (when deciding the page language). + $title->mRedirect = false; + + $this->testParser->setTitle( $title ); + } + + /** + * @param int $num Upper limit for numbers + * @return array Array of numbers from 1 up to $num + */ + private static function createProviderUpTo( $num ) { + $ret = array(); + for ( $i = 1; $i <= $num; $i++ ) { + $ret[] = array( $i ); + } + + return $ret; + } + + /** + * @return array Array of months numbers (as an integer) + */ + public static function provideMonths() { + return self::createProviderUpTo( 12 ); + } + + /** + * @return array Array of days numbers (as an integer) + */ + public static function provideDays() { + return self::createProviderUpTo( 31 ); + } + + ############### TESTS ############################################# + # @todo FIXME: + # - those got copy pasted, we can probably make them cleaner + # - tests are lacking useful messages + + # day + + /** @dataProvider provideDays */ + public function testCurrentdayIsUnPadded( $day ) { + $this->assertUnPadded( 'currentday', $day ); + } + + /** @dataProvider provideDays */ + public function testCurrentdaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'currentday2', $day ); + } + + /** @dataProvider provideDays */ + public function testLocaldayIsUnPadded( $day ) { + $this->assertUnPadded( 'localday', $day ); + } + + /** @dataProvider provideDays */ + public function testLocaldaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'localday2', $day ); + } + + # month + + /** @dataProvider provideMonths */ + public function testCurrentmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'currentmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testCurrentmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'currentmonth1', $month ); + } + + /** @dataProvider provideMonths */ + public function testLocalmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'localmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testLocalmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'localmonth1', $month ); + } + + # revision day + + /** @dataProvider provideDays */ + public function testRevisiondayIsUnPadded( $day ) { + $this->assertUnPadded( 'revisionday', $day ); + } + + /** @dataProvider provideDays */ + public function testRevisiondaytwoIsZeroPadded( $day ) { + $this->assertZeroPadded( 'revisionday2', $day ); + } + + # revision month + + /** @dataProvider provideMonths */ + public function testRevisionmonthIsZeroPadded( $month ) { + $this->assertZeroPadded( 'revisionmonth', $month ); + } + + /** @dataProvider provideMonths */ + public function testRevisionmonthoneIsUnPadded( $month ) { + $this->assertUnPadded( 'revisionmonth1', $month ); + } + + ############### HELPERS ############################################ + + /** assertion helper expecting a magic output which is zero padded */ + public function assertZeroPadded( $magic, $value ) { + $this->assertMagicPadding( $magic, $value, '%02d' ); + } + + /** assertion helper expecting a magic output which is unpadded */ + public function assertUnPadded( $magic, $value ) { + $this->assertMagicPadding( $magic, $value, '%d' ); + } + + /** + * Main assertion helper for magic variables padding + * @param string $magic Magic variable name + * @param mixed $value Month or day + * @param string $format Sprintf format for $value + */ + private function assertMagicPadding( $magic, $value, $format ) { + # Initialize parser timestamp as year 2010 at 12h34 56s. + # month and day are given by the caller ($value). Month < 12! + if ( $value > 12 ) { + $month = $value % 12; + } else { + $month = $value; + } + + $this->setParserTS( + sprintf( '2010%02d%02d123456', $month, $value ) + ); + + # please keep the following commented line of code. It helps debugging. + //print "\nDEBUG (value $value):" . sprintf( '2010%02d%02d123456', $value, $value ) . "\n"; + + # format expectation and test it + $expected = sprintf( $format, $value ); + $this->assertMagic( $expected, $magic ); + } + + /** + * helper to set the parser timestamp and revision timestamp + * @param string $ts + */ + private function setParserTS( $ts ) { + $this->testParser->Options()->setTimestamp( $ts ); + $this->testParser->mRevisionTimestamp = $ts; + } + + /** + * Assertion helper to test a magic variable output + * @param string|int $expected + * @param string $magic + */ + private function assertMagic( $expected, $magic ) { + if ( in_array( $magic, $this->expectedAsInteger ) ) { + $expected = (int)$expected; + } + + # Generate a message for the assertion + $msg = sprintf( "Magic %s should be <%s:%s>", + $magic, + $expected, + gettype( $expected ) + ); + + $this->assertSame( + $expected, + $this->testParser->getVariableValue( $magic ), + $msg + ); + } +} diff --git a/tests/phpunit/includes/parser/MediaWikiParserTest.php b/tests/phpunit/includes/parser/MediaWikiParserTest.php new file mode 100644 index 00000000..df891f5a --- /dev/null +++ b/tests/phpunit/includes/parser/MediaWikiParserTest.php @@ -0,0 +1,134 @@ +<?php +require_once __DIR__ . '/NewParserTest.php'; + +/** + * The UnitTest must be either a class that inherits from MediaWikiTestCase + * or a class that provides a public static suite() method which returns + * an PHPUnit_Framework_Test object + * + * @group Parser + * @group Database + */ +class MediaWikiParserTest { + + /** + * @defgroup filtering_constants Filtering constants + * + * Limit inclusion of parser tests files coming from MediaWiki core + * @{ + */ + + /** Include files shipped with MediaWiki core */ + const CORE_ONLY = 1; + /** Include non core files as set in $wgParserTestFiles */ + const NO_CORE = 2; + /** Include anything set via $wgParserTestFiles */ + const WITH_ALL = 3; # CORE_ONLY | NO_CORE + + /** @} */ + + /** + * Get a PHPUnit test suite of parser tests. Optionally filtered with + * $flags. + * + * @par Examples: + * Get a suite of parser tests shipped by MediaWiki core: + * @code + * MediaWikiParserTest::suite( MediaWikiParserTest::CORE_ONLY ); + * @endcode + * Get a suite of various parser tests, like extensions: + * @code + * MediaWikiParserTest::suite( MediaWikiParserTest::NO_CORE ); + * @endcode + * Get any test defined via $wgParserTestFiles: + * @code + * MediaWikiParserTest::suite( MediaWikiParserTest::WITH_ALL ); + * @endcode + * + * @param int $flags Bitwise flag to filter out the $wgParserTestFiles that + * will be included. Default: MediaWikiParserTest::CORE_ONLY + * + * @return PHPUnit_Framework_TestSuite + */ + public static function suite( $flags = self::CORE_ONLY ) { + if ( is_string( $flags ) ) { + $flags = self::CORE_ONLY; + } + global $wgParserTestFiles, $IP; + + $mwTestDir = $IP . '/tests/'; + + # Human friendly helpers + $wantsCore = ( $flags & self::CORE_ONLY ); + $wantsRest = ( $flags & self::NO_CORE ); + + # Will hold the .txt parser test files we will include + $filesToTest = array(); + + # Filter out .txt files + foreach ( $wgParserTestFiles as $parserTestFile ) { + $isCore = ( 0 === strpos( $parserTestFile, $mwTestDir ) ); + + if ( $isCore && $wantsCore ) { + self::debug( "included core parser tests: $parserTestFile" ); + $filesToTest[] = $parserTestFile; + } elseif ( !$isCore && $wantsRest ) { + self::debug( "included non core parser tests: $parserTestFile" ); + $filesToTest[] = $parserTestFile; + } else { + self::debug( "skipped parser tests: $parserTestFile" ); + } + } + self::debug( 'parser tests files: ' + . implode( ' ', $filesToTest ) ); + + $suite = new PHPUnit_Framework_TestSuite; + $testList = array(); + $counter = 0; + foreach ( $filesToTest as $fileName ) { + // Call the highest level directory the extension name. + // It may or may not actually be, but it should be close + // enough to cause there to be separate names for different + // things, which is good enough for our purposes. + $extensionName = basename( dirname( $fileName ) ); + $testsName = $extensionName . '⁄' . basename( $fileName, '.txt' ); + $escapedFileName = strtr( $fileName, array( "'" => "\\'", '\\' => '\\\\' ) ); + $parserTestClassName = ucfirst( $testsName ); + // Official spec for class names: http://php.net/manual/en/language.oop5.basic.php + // Prepend 'ParserTest_' to be paranoid about it not starting with a number + $parserTestClassName = 'ParserTest_' . preg_replace( '/[^a-zA-Z0-9_\x7f-\xff]/', '_', $parserTestClassName ); + if ( isset( $testList[$parserTestClassName] ) ) { + // If a conflict happens, gives a very unclear fatal. + // So as a last ditch effort to prevent that eventuality, if there + // is a conflict, append a number. + $counter++; + $parserTestClassName .= $counter; + } + $testList[$parserTestClassName] = true; + $parserTestClassDefinition = <<<EOT +/** + * @group Database + * @group Parser + * @group ParserTests + * @group ParserTests_$parserTestClassName + */ +class $parserTestClassName extends NewParserTest { + protected \$file = '$escapedFileName'; +} +EOT; + + eval( $parserTestClassDefinition ); + self::debug( "Adding test class $parserTestClassName" ); + $suite->addTestSuite( $parserTestClassName ); + } + return $suite; + } + + /** + * Write $msg under log group 'tests-parser' + * @param string $msg Message to log + */ + protected static function debug( $msg ) { + return wfDebugLog( 'tests-parser', wfGetCaller() . ' ' . $msg ); + } +} diff --git a/tests/phpunit/includes/parser/NewParserTest.php b/tests/phpunit/includes/parser/NewParserTest.php new file mode 100644 index 00000000..0df52f5e --- /dev/null +++ b/tests/phpunit/includes/parser/NewParserTest.php @@ -0,0 +1,1091 @@ +<?php + +/** + * Although marked as a stub, can work independently. + * + * @group Database + * @group Parser + * @group Stub + * + * @todo covers tags + */ +class NewParserTest extends MediaWikiTestCase { + static protected $articles = array(); // Array of test articles defined by the tests + /* The data provider is run on a different instance than the test, so it must be static + * When running tests from several files, all tests will see all articles. + */ + static protected $backendToUse; + + public $keepUploads = false; + public $runDisabled = false; + public $runParsoid = false; + public $regex = ''; + public $showProgress = true; + public $savedWeirdGlobals = array(); + public $savedGlobals = array(); + public $hooks = array(); + public $functionHooks = array(); + public $transparentHooks = array(); + + //Fuzz test + public $maxFuzzTestLength = 300; + public $fuzzSeed = 0; + public $memoryLimit = 50; + + /** + * @var DjVuSupport + */ + private $djVuSupport; + /** + * @var TidySupport + */ + private $tidySupport; + + protected $file = false; + + public static function setUpBeforeClass() { + // Inject ParserTest well-known interwikis + ParserTest::setupInterwikis(); + } + + protected function setUp() { + global $wgNamespaceAliases, $wgContLang; + global $wgHooks, $IP; + + parent::setUp(); + + //Setup CLI arguments + if ( $this->getCliArg( 'regex' ) ) { + $this->regex = $this->getCliArg( 'regex' ); + } else { + # Matches anything + $this->regex = ''; + } + + $this->keepUploads = $this->getCliArg( 'keep-uploads' ); + + $tmpGlobals = array(); + + $tmpGlobals['wgLanguageCode'] = 'en'; + $tmpGlobals['wgContLang'] = Language::factory( 'en' ); + $tmpGlobals['wgSitename'] = 'MediaWiki'; + $tmpGlobals['wgServer'] = 'http://example.org'; + $tmpGlobals['wgServerName'] = 'example.org'; + $tmpGlobals['wgScript'] = '/index.php'; + $tmpGlobals['wgScriptPath'] = '/'; + $tmpGlobals['wgArticlePath'] = '/wiki/$1'; + $tmpGlobals['wgActionPaths'] = array(); + $tmpGlobals['wgVariantArticlePath'] = false; + $tmpGlobals['wgExtensionAssetsPath'] = '/extensions'; + $tmpGlobals['wgStylePath'] = '/skins'; + $tmpGlobals['wgEnableUploads'] = true; + $tmpGlobals['wgUploadNavigationUrl'] = false; + $tmpGlobals['wgThumbnailScriptPath'] = false; + $tmpGlobals['wgLocalFileRepo'] = array( + 'class' => 'LocalRepo', + 'name' => 'local', + 'url' => 'http://example.com/images', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => 'local-backend' + ); + $tmpGlobals['wgForeignFileRepos'] = array(); + $tmpGlobals['wgDefaultExternalStore'] = array(); + $tmpGlobals['wgEnableParserCache'] = false; + $tmpGlobals['wgCapitalLinks'] = true; + $tmpGlobals['wgNoFollowLinks'] = true; + $tmpGlobals['wgNoFollowDomainExceptions'] = array(); + $tmpGlobals['wgExternalLinkTarget'] = false; + $tmpGlobals['wgThumbnailScriptPath'] = false; + $tmpGlobals['wgUseImageResize'] = true; + $tmpGlobals['wgAllowExternalImages'] = true; + $tmpGlobals['wgRawHtml'] = false; + $tmpGlobals['wgWellFormedXml'] = true; + $tmpGlobals['wgAllowMicrodataAttributes'] = true; + $tmpGlobals['wgExperimentalHtmlIds'] = false; + $tmpGlobals['wgAdaptiveMessageCache'] = true; + $tmpGlobals['wgUseDatabaseMessages'] = true; + $tmpGlobals['wgLocaltimezone'] = 'UTC'; + $tmpGlobals['wgDeferredUpdateList'] = array(); + $tmpGlobals['wgGroupPermissions'] = array( + '*' => array( + 'createaccount' => true, + 'read' => true, + 'edit' => true, + 'createpage' => true, + 'createtalk' => true, + ) ); + $tmpGlobals['wgNamespaceProtection'] = array( NS_MEDIAWIKI => 'editinterface' ); + + $tmpGlobals['wgParser'] = new StubObject( + 'wgParser', $GLOBALS['wgParserConf']['class'], + array( $GLOBALS['wgParserConf'] ) ); + + $tmpGlobals['wgFileExtensions'][] = 'svg'; + $tmpGlobals['wgSVGConverter'] = 'rsvg'; + $tmpGlobals['wgSVGConverters']['rsvg'] = + '$path/rsvg-convert -w $width -h $height $input -o $output'; + + if ( $GLOBALS['wgStyleDirectory'] === false ) { + $tmpGlobals['wgStyleDirectory'] = "$IP/skins"; + } + + # Replace all media handlers with a mock. We do not need to generate + # actual thumbnails to do parser testing, we only care about receiving + # a ThumbnailImage properly initialized. + global $wgMediaHandlers; + foreach ( $wgMediaHandlers as $type => $handler ) { + $tmpGlobals['wgMediaHandlers'][$type] = 'MockBitmapHandler'; + } + // Vector images have to be handled slightly differently + $tmpGlobals['wgMediaHandlers']['image/svg+xml'] = 'MockSvgHandler'; + + // DjVu images have to be handled slightly differently + $tmpGlobals['wgMediaHandlers']['image/vnd.djvu'] = 'MockDjVuHandler'; + + $tmpHooks = $wgHooks; + $tmpHooks['ParserTestParser'][] = 'ParserTestParserHook::setup'; + $tmpHooks['ParserGetVariableValueTs'][] = 'ParserTest::getFakeTimestamp'; + $tmpGlobals['wgHooks'] = $tmpHooks; + # add a namespace shadowing a interwiki link, to test + # proper precedence when resolving links. (bug 51680) + $tmpGlobals['wgExtraNamespaces'] = array( 100 => 'MemoryAlpha' ); + + $tmpGlobals['wgLocalInterwikis'] = array( 'local', 'mi' ); + # "extra language links" + # see https://gerrit.wikimedia.org/r/111390 + $tmpGlobals['wgExtraInterlanguageLinkPrefixes'] = array( 'mul' ); + + // DjVu support + $this->djVuSupport = new DjVuSupport(); + // Tidy support + $this->tidySupport = new TidySupport(); + // We always set 'wgUseTidy' to false when parsing, but certain + // test-running modes still use tidy if available, so ensure + // that the tidy-related options are all set to their defaults. + $tmpGlobals['wgUseTidy'] = false; + $tmpGlobals['wgAlwaysUseTidy'] = false; + $tmpGlobals['wgDebugTidy'] = false; + $tmpGlobals['wgTidyConf'] = $IP . '/includes/tidy.conf'; + $tmpGlobals['wgTidyOpts'] = ''; + $tmpGlobals['wgTidyInternal'] = $this->tidySupport->isInternal(); + + $this->setMwGlobals( $tmpGlobals ); + + $this->savedWeirdGlobals['image_alias'] = $wgNamespaceAliases['Image']; + $this->savedWeirdGlobals['image_talk_alias'] = $wgNamespaceAliases['Image_talk']; + + $wgNamespaceAliases['Image'] = NS_FILE; + $wgNamespaceAliases['Image_talk'] = NS_FILE_TALK; + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function tearDown() { + global $wgNamespaceAliases, $wgContLang; + + $wgNamespaceAliases['Image'] = $this->savedWeirdGlobals['image_alias']; + $wgNamespaceAliases['Image_talk'] = $this->savedWeirdGlobals['image_talk_alias']; + + // Restore backends + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + + // Remove temporary pages from the link cache + LinkCache::singleton()->clear(); + + // Restore message cache (temporary pages and $wgUseDatabaseMessages) + MessageCache::destroyInstance(); + + parent::tearDown(); + + MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache + $wgContLang->resetNamespaces(); # reset namespace cache + } + + public static function tearDownAfterClass() { + ParserTest::tearDownInterwikis(); + parent::tearDownAfterClass(); + } + + function addDBData() { + $this->tablesUsed[] = 'site_stats'; + # disabled for performance + #$this->tablesUsed[] = 'image'; + + # Update certain things in site_stats + $this->db->insert( 'site_stats', + array( 'ss_row_id' => 1, 'ss_images' => 2, 'ss_good_articles' => 1 ), + __METHOD__ + ); + + $user = User::newFromId( 0 ); + LinkCache::singleton()->clear(); # Avoids the odd failure at creating the nullRevision + + # Upload DB table entries for files. + # We will upload the actual files later. Note that if anything causes LocalFile::load() + # to be triggered before then, it will break via maybeUpgrade() setting the fileExists + # member to false and storing it in cache. + # note that the size/width/height/bits/etc of the file + # are actually set by inspecting the file itself; the arguments + # to recordUpload2 have no effect. That said, we try to make things + # match up so it is less confusing to readers of the code & tests. + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.jpg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'Upload of some lame file', + 'Some lame file', + array( + 'size' => 7881, + 'width' => 1941, + 'height' => 220, + 'bits' => 8, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/jpeg', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '1', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20010115123500' ), $user + ); + } + + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Thumb.png' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'Upload of some lame thumbnail', + 'Some lame thumbnail', + array( + 'size' => 22589, + 'width' => 135, + 'height' => 135, + 'bits' => 8, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/png', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '2', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20130225203040' ), $user + ); + } + + # This image will be blacklisted in [[MediaWiki:Bad image list]] + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Bad.jpg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( + '', // archive name + 'zomgnotcensored', + 'Borderline image', + array( + 'size' => 12345, + 'width' => 320, + 'height' => 240, + 'bits' => 24, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/jpeg', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '3', 16, 36, 31 ), + 'fileExists' => true ), + $this->db->timestamp( '20010115123500' ), $user + ); + } + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'Foobar.svg' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( '', 'Upload of some lame SVG', 'Some lame SVG', array( + 'size' => 12345, + 'width' => 240, + 'height' => 180, + 'bits' => 0, + 'media_type' => MEDIATYPE_DRAWING, + 'mime' => 'image/svg+xml', + 'metadata' => serialize( array() ), + 'sha1' => wfBaseConvert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20010115123500' ), $user ); + } + + # A DjVu file + $image = wfLocalFile( Title::makeTitle( NS_FILE, 'LoremIpsum.djvu' ) ); + if ( !$this->db->selectField( 'image', '1', array( 'img_name' => $image->getName() ) ) ) { + $image->recordUpload2( '', 'Upload a DjVu', 'A DjVu', array( + 'size' => 3249, + 'width' => 2480, + 'height' => 3508, + 'bits' => 0, + 'media_type' => MEDIATYPE_BITMAP, + 'mime' => 'image/vnd.djvu', + 'metadata' => '<?xml version="1.0" ?> +<!DOCTYPE DjVuXML PUBLIC "-//W3C//DTD DjVuXML 1.1//EN" "pubtext/DjVuXML-s.dtd"> +<DjVuXML> +<HEAD></HEAD> +<BODY><OBJECT height="3508" width="2480"> +<PARAM name="DPI" value="300" /> +<PARAM name="GAMMA" value="2.2" /> +</OBJECT> +<OBJECT height="3508" width="2480"> +<PARAM name="DPI" value="300" /> +<PARAM name="GAMMA" value="2.2" /> +</OBJECT> +<OBJECT height="3508" width="2480"> +<PARAM name="DPI" value="300" /> +<PARAM name="GAMMA" value="2.2" /> +</OBJECT> +<OBJECT height="3508" width="2480"> +<PARAM name="DPI" value="300" /> +<PARAM name="GAMMA" value="2.2" /> +</OBJECT> +<OBJECT height="3508" width="2480"> +<PARAM name="DPI" value="300" /> +<PARAM name="GAMMA" value="2.2" /> +</OBJECT> +</BODY> +</DjVuXML>', + 'sha1' => wfBaseConvert( '', 16, 36, 31 ), + 'fileExists' => true + ), $this->db->timestamp( '20140115123600' ), $user ); + } + } + + //ParserTest setup/teardown functions + + /** + * Set up the global variables for a consistent environment for each test. + * Ideally this should replace the global configuration entirely. + * @param array $opts + * @param string $config + * @return RequestContext + */ + protected function setupGlobals( $opts = array(), $config = '' ) { + global $wgFileBackends; + # Find out values for some special options. + $lang = + self::getOptionValue( 'language', $opts, 'en' ); + $variant = + self::getOptionValue( 'variant', $opts, false ); + $maxtoclevel = + self::getOptionValue( 'wgMaxTocLevel', $opts, 999 ); + $linkHolderBatchSize = + self::getOptionValue( 'wgLinkHolderBatchSize', $opts, 1000 ); + + $uploadDir = $this->getUploadDir(); + if ( $this->getCliArg( 'use-filebackend' ) ) { + if ( self::$backendToUse ) { + $backend = self::$backendToUse; + } else { + $name = $this->getCliArg( 'use-filebackend' ); + $useConfig = array(); + foreach ( $wgFileBackends as $conf ) { + if ( $conf['name'] == $name ) { + $useConfig = $conf; + } + } + $useConfig['name'] = 'local-backend'; // swap name + unset( $useConfig['lockManager'] ); + unset( $useConfig['fileJournal'] ); + $class = $useConfig['class']; + self::$backendToUse = new $class( $useConfig ); + $backend = self::$backendToUse; + } + } else { + # Replace with a mock. We do not care about generating real + # files on the filesystem, just need to expose the file + # informations. + $backend = new MockFileBackend( array( + 'name' => 'local-backend', + 'wikiId' => wfWikiId() + ) ); + } + + $settings = array( + 'wgLocalFileRepo' => array( + 'class' => 'LocalRepo', + 'name' => 'local', + 'url' => 'http://example.com/images', + 'hashLevels' => 2, + 'transformVia404' => false, + 'backend' => $backend + ), + 'wgEnableUploads' => self::getOptionValue( 'wgEnableUploads', $opts, true ), + 'wgLanguageCode' => $lang, + 'wgDBprefix' => $this->db->getType() != 'oracle' ? 'unittest_' : 'ut_', + 'wgRawHtml' => self::getOptionValue( 'wgRawHtml', $opts, false ), + 'wgNamespacesWithSubpages' => array( NS_MAIN => isset( $opts['subpage'] ) ), + 'wgAllowExternalImages' => self::getOptionValue( 'wgAllowExternalImages', $opts, true ), + 'wgThumbLimits' => array( self::getOptionValue( 'thumbsize', $opts, 180 ) ), + 'wgMaxTocLevel' => $maxtoclevel, + 'wgUseTeX' => isset( $opts['math'] ) || isset( $opts['texvc'] ), + 'wgMathDirectory' => $uploadDir . '/math', + 'wgDefaultLanguageVariant' => $variant, + 'wgLinkHolderBatchSize' => $linkHolderBatchSize, + ); + + if ( $config ) { + $configLines = explode( "\n", $config ); + + foreach ( $configLines as $line ) { + list( $var, $value ) = explode( '=', $line, 2 ); + + $settings[$var] = eval( "return $value;" ); //??? + } + } + + $this->savedGlobals = array(); + + /** @since 1.20 */ + wfRunHooks( 'ParserTestGlobals', array( &$settings ) ); + + $langObj = Language::factory( $lang ); + $settings['wgContLang'] = $langObj; + $settings['wgLang'] = $langObj; + + $context = new RequestContext(); + $settings['wgOut'] = $context->getOutput(); + $settings['wgUser'] = $context->getUser(); + $settings['wgRequest'] = $context->getRequest(); + + // We (re)set $wgThumbLimits to a single-element array above. + $context->getUser()->setOption( 'thumbsize', 0 ); + + foreach ( $settings as $var => $val ) { + if ( array_key_exists( $var, $GLOBALS ) ) { + $this->savedGlobals[$var] = $GLOBALS[$var]; + } + + $GLOBALS[$var] = $val; + } + + MagicWord::clearCache(); + + # The entries saved into RepoGroup cache with previous globals will be wrong. + RepoGroup::destroySingleton(); + FileBackendGroup::destroySingleton(); + + # Create dummy files in storage + $this->setupUploads(); + + # Publish the articles after we have the final language set + $this->publishTestArticles(); + + MessageCache::destroyInstance(); + + return $context; + } + + /** + * Get an FS upload directory (only applies to FSFileBackend) + * + * @return string The directory + */ + protected function getUploadDir() { + if ( $this->keepUploads ) { + $dir = wfTempDir() . '/mwParser-images'; + + if ( is_dir( $dir ) ) { + return $dir; + } + } else { + $dir = wfTempDir() . "/mwParser-" . mt_rand() . "-images"; + } + + // wfDebug( "Creating upload directory $dir\n" ); + if ( file_exists( $dir ) ) { + wfDebug( "Already exists!\n" ); + + return $dir; + } + + return $dir; + } + + /** + * Create a dummy uploads directory which will contain a couple + * of files in order to pass existence tests. + * + * @return string The directory + */ + protected function setupUploads() { + global $IP; + + $base = $this->getBaseDir(); + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + $backend->prepare( array( 'dir' => "$base/local-public/3/3a" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", + 'dst' => "$base/local-public/3/3a/Foobar.jpg" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/e/ea" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/wiki.png", + 'dst' => "$base/local-public/e/ea/Thumb.png" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/0/09" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/headbg.jpg", + 'dst' => "$base/local-public/0/09/Bad.jpg" + ) ); + $backend->prepare( array( 'dir' => "$base/local-public/5/5f" ) ); + $backend->store( array( + 'src' => "$IP/tests/phpunit/data/parser/LoremIpsum.djvu", + 'dst' => "$base/local-public/5/5f/LoremIpsum.djvu" + ) ); + + // No helpful SVG file to copy, so make one ourselves + $data = '<?xml version="1.0" encoding="utf-8"?>' . + '<svg xmlns="http://www.w3.org/2000/svg"' . + ' version="1.1" width="240" height="180"/>'; + + $backend->prepare( array( 'dir' => "$base/local-public/f/ff" ) ); + $backend->quickCreate( array( + 'content' => $data, 'dst' => "$base/local-public/f/ff/Foobar.svg" + ) ); + } + + /** + * Restore default values and perform any necessary clean-up + * after each test runs. + */ + protected function teardownGlobals() { + $this->teardownUploads(); + + foreach ( $this->savedGlobals as $var => $val ) { + $GLOBALS[$var] = $val; + } + } + + /** + * Remove the dummy uploads directory + */ + private function teardownUploads() { + if ( $this->keepUploads ) { + return; + } + + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + if ( $backend instanceof MockFileBackend ) { + # In memory backend, so dont bother cleaning them up. + return; + } + + $base = $this->getBaseDir(); + // delete the files first, then the dirs. + self::deleteFiles( + array( + "$base/local-public/3/3a/Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/100px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/120px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1280px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/137px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/177px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/180px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/200px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/206px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/20px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/220px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/265px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/270px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/274px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/300px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/30px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/330px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/353px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/360px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/400px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/40px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/440px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/442px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/450px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/50px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/600px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/640px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/70px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/75px-Foobar.jpg", + "$base/local-thumb/3/3a/Foobar.jpg/960px-Foobar.jpg", + + "$base/local-public/e/ea/Thumb.png", + + "$base/local-public/0/09/Bad.jpg", + + "$base/local-public/5/5f/LoremIpsum.djvu", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-2480px-LoremIpsum.djvu.jpg", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-3720px-LoremIpsum.djvu.jpg", + "$base/local-thumb/5/5f/LoremIpsum.djvu/page2-4960px-LoremIpsum.djvu.jpg", + + "$base/local-public/f/ff/Foobar.svg", + "$base/local-thumb/f/ff/Foobar.svg/180px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/270px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/360px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-180px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-270px-Foobar.svg.png", + "$base/local-thumb/f/ff/Foobar.svg/langde-360px-Foobar.svg.png", + + "$base/local-public/math/f/a/5/fa50b8b616463173474302ca3e63586b.png", + ) + ); + } + + /** + * Delete the specified files, if they exist. + * @param array $files Full paths to files to delete. + */ + private static function deleteFiles( $files ) { + $backend = RepoGroup::singleton()->getLocalRepo()->getBackend(); + foreach ( $files as $file ) { + $backend->delete( array( 'src' => $file ), array( 'force' => 1 ) ); + } + foreach ( $files as $file ) { + $tmp = $file; + while ( $tmp = FileBackend::parentStoragePath( $tmp ) ) { + if ( !$backend->clean( array( 'dir' => $tmp ) )->isOK() ) { + break; + } + } + } + } + + protected function getBaseDir() { + return 'mwstore://local-backend'; + } + + public function parserTestProvider() { + if ( $this->file === false ) { + global $wgParserTestFiles; + $this->file = $wgParserTestFiles[0]; + } + + return new TestFileIterator( $this->file, $this ); + } + + /** + * Set the file from whose tests will be run by this instance + * @param string $filename + */ + public function setParserTestFile( $filename ) { + $this->file = $filename; + } + + /** + * @group medium + * @dataProvider parserTestProvider + * @param string $desc + * @param string $input + * @param string $result + * @param array $opts + * @param array $config + */ + public function testParserTest( $desc, $input, $result, $opts, $config ) { + if ( $this->regex != '' && !preg_match( '/' . $this->regex . '/', $desc ) ) { + $this->assertTrue( true ); // XXX: don't flood output with "test made no assertions" + //$this->markTestSkipped( 'Filtered out by the user' ); + return; + } + + if ( !$this->isWikitextNS( NS_MAIN ) ) { + // parser tests frequently assume that the main namespace contains wikitext. + // @todo When setting up pages, force the content model. Only skip if + // $wgtContentModelUseDB is false. + $this->markTestSkipped( "Main namespace does not support wikitext," + . "skipping parser test: $desc" ); + } + + wfDebug( "Running parser test: $desc\n" ); + + $opts = $this->parseOptions( $opts ); + $context = $this->setupGlobals( $opts, $config ); + + $user = $context->getUser(); + $options = ParserOptions::newFromContext( $context ); + + if ( isset( $opts['title'] ) ) { + $titleText = $opts['title']; + } else { + $titleText = 'Parser test'; + } + + $local = isset( $opts['local'] ); + $preprocessor = isset( $opts['preprocessor'] ) ? $opts['preprocessor'] : null; + $parser = $this->getParser( $preprocessor ); + + $title = Title::newFromText( $titleText ); + + # Parser test requiring math. Make sure texvc is executable + # or just skip such tests. + if ( isset( $opts['math'] ) || isset( $opts['texvc'] ) ) { + global $wgTexvc; + + if ( !isset( $wgTexvc ) ) { + $this->markTestSkipped( "SKIPPED: \$wgTexvc is not set" ); + } elseif ( !is_executable( $wgTexvc ) ) { + $this->markTestSkipped( "SKIPPED: texvc binary does not exist" + . " or is not executable.\n" + . "Current configuration is:\n\$wgTexvc = '$wgTexvc'" ); + } + } + if ( isset( $opts['djvu'] ) ) { + if ( !$this->djVuSupport->isEnabled() ) { + $this->markTestSkipped( "SKIPPED: djvu binaries do not exist or are not executable.\n" ); + } + } + + if ( isset( $opts['pst'] ) ) { + $out = $parser->preSaveTransform( $input, $title, $user, $options ); + } elseif ( isset( $opts['msg'] ) ) { + $out = $parser->transformMsg( $input, $options, $title ); + } elseif ( isset( $opts['section'] ) ) { + $section = $opts['section']; + $out = $parser->getSection( $input, $section ); + } elseif ( isset( $opts['replace'] ) ) { + $section = $opts['replace'][0]; + $replace = $opts['replace'][1]; + $out = $parser->replaceSection( $input, $section, $replace ); + } elseif ( isset( $opts['comment'] ) ) { + $out = Linker::formatComment( $input, $title, $local ); + } elseif ( isset( $opts['preload'] ) ) { + $out = $parser->getPreloadText( $input, $title, $options ); + } else { + $output = $parser->parse( $input, $title, $options, true, true, 1337 ); + $output->setTOCEnabled( !isset( $opts['notoc'] ) ); + $out = $output->getText(); + if ( isset( $opts['tidy'] ) ) { + if ( !$this->tidySupport->isEnabled() ) { + $this->markTestSkipped( "SKIPPED: tidy extension is not installed.\n" ); + } else { + $out = MWTidy::tidy( $out ); + $out = preg_replace( '/\s+$/', '', $out ); + } + } + + if ( isset( $opts['showtitle'] ) ) { + if ( $output->getTitleText() ) { + $title = $output->getTitleText(); + } + + $out = "$title\n$out"; + } + + if ( isset( $opts['ill'] ) ) { + $out = implode( ' ', $output->getLanguageLinks() ); + } elseif ( isset( $opts['cat'] ) ) { + $outputPage = $context->getOutput(); + $outputPage->addCategoryLinks( $output->getCategories() ); + $cats = $outputPage->getCategoryLinks(); + + if ( isset( $cats['normal'] ) ) { + $out = implode( ' ', $cats['normal'] ); + } else { + $out = ''; + } + } + $parser->mPreprocessor = null; + } + + $this->teardownGlobals(); + + $this->assertEquals( $result, $out, $desc ); + } + + /** + * Run a fuzz test series + * Draw input from a set of test files + * + * @todo fixme Needs some work to not eat memory until the world explodes + * + * @group ParserFuzz + */ + public function testFuzzTests() { + global $wgParserTestFiles; + + $files = $wgParserTestFiles; + + if ( $this->getCliArg( 'file' ) ) { + $files = array( $this->getCliArg( 'file' ) ); + } + + $dict = $this->getFuzzInput( $files ); + $dictSize = strlen( $dict ); + $logMaxLength = log( $this->maxFuzzTestLength ); + + ini_set( 'memory_limit', $this->memoryLimit * 1048576 ); + + $user = new User; + $opts = ParserOptions::newFromUser( $user ); + $title = Title::makeTitle( NS_MAIN, 'Parser_test' ); + + $id = 1; + + while ( true ) { + + // Generate test input + mt_srand( ++$this->fuzzSeed ); + $totalLength = mt_rand( 1, $this->maxFuzzTestLength ); + $input = ''; + + while ( strlen( $input ) < $totalLength ) { + $logHairLength = mt_rand( 0, 1000000 ) / 1000000 * $logMaxLength; + $hairLength = min( intval( exp( $logHairLength ) ), $dictSize ); + $offset = mt_rand( 0, $dictSize - $hairLength ); + $input .= substr( $dict, $offset, $hairLength ); + } + + $this->setupGlobals(); + $parser = $this->getParser(); + + // Run the test + try { + $parser->parse( $input, $title, $opts ); + $this->assertTrue( true, "Test $id, fuzz seed {$this->fuzzSeed}" ); + } catch ( Exception $exception ) { + $input_dump = sprintf( "string(%d) \"%s\"\n", strlen( $input ), $input ); + + $this->assertTrue( false, "Test $id, fuzz seed {$this->fuzzSeed}. \n\n" . + "Input: $input_dump\n\nError: {$exception->getMessage()}\n\n" . + "Backtrace: {$exception->getTraceAsString()}" ); + } + + $this->teardownGlobals(); + $parser->__destruct(); + + if ( $id % 100 == 0 ) { + $usage = intval( memory_get_usage( true ) / $this->memoryLimit / 1048576 * 100 ); + //echo "{$this->fuzzSeed}: $numSuccess/$numTotal (mem: $usage%)\n"; + if ( $usage > 90 ) { + $ret = "Out of memory:\n"; + $memStats = $this->getMemoryBreakdown(); + + foreach ( $memStats as $name => $usage ) { + $ret .= "$name: $usage\n"; + } + + throw new MWException( $ret ); + } + } + + $id++; + } + } + + //Various getter functions + + /** + * Get an input dictionary from a set of parser test files + * @param array $filenames + * @return string + */ + function getFuzzInput( $filenames ) { + $dict = ''; + + foreach ( $filenames as $filename ) { + $contents = file_get_contents( $filename ); + preg_match_all( '/!!\s*input\n(.*?)\n!!\s*result/s', $contents, $matches ); + + foreach ( $matches[1] as $match ) { + $dict .= $match . "\n"; + } + } + + return $dict; + } + + /** + * Get a memory usage breakdown + * @return array + */ + function getMemoryBreakdown() { + $memStats = array(); + + foreach ( $GLOBALS as $name => $value ) { + $memStats['$' . $name] = strlen( serialize( $value ) ); + } + + $classes = get_declared_classes(); + + foreach ( $classes as $class ) { + $rc = new ReflectionClass( $class ); + $props = $rc->getStaticProperties(); + $memStats[$class] = strlen( serialize( $props ) ); + $methods = $rc->getMethods(); + + foreach ( $methods as $method ) { + $memStats[$class] += strlen( serialize( $method->getStaticVariables() ) ); + } + } + + $functions = get_defined_functions(); + + foreach ( $functions['user'] as $function ) { + $rf = new ReflectionFunction( $function ); + $memStats["$function()"] = strlen( serialize( $rf->getStaticVariables() ) ); + } + + asort( $memStats ); + + return $memStats; + } + + /** + * Get a Parser object + * @param Preprocessor $preprocessor + * @return Parser + */ + function getParser( $preprocessor = null ) { + global $wgParserConf; + + $class = $wgParserConf['class']; + $parser = new $class( array( 'preprocessorClass' => $preprocessor ) + $wgParserConf ); + + wfRunHooks( 'ParserTestParser', array( &$parser ) ); + + return $parser; + } + + //Various action functions + + public function addArticle( $name, $text, $line ) { + self::$articles[$name] = array( $text, $line ); + } + + public function publishTestArticles() { + if ( empty( self::$articles ) ) { + return; + } + + foreach ( self::$articles as $name => $info ) { + list( $text, $line ) = $info; + ParserTest::addArticle( $name, $text, $line, 'ignoreduplicate' ); + } + } + + /** + * Steal a callback function from the primary parser, save it for + * application to our scary parser. If the hook is not installed, + * abort processing of this file. + * + * @param string $name + * @return bool True if tag hook is present + */ + public function requireHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mTagHooks[$name] ); + } + + public function requireFunctionHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mFunctionHooks[$name] ); + } + + public function requireTransparentHook( $name ) { + global $wgParser; + $wgParser->firstCallInit(); // make sure hooks are loaded. + return isset( $wgParser->mTransparentTagHooks[$name] ); + } + + //Various "cleanup" functions + + /** + * Remove last character if it is a newline + * @param string $s + * @return string + */ + public function removeEndingNewline( $s ) { + if ( substr( $s, -1 ) === "\n" ) { + return substr( $s, 0, -1 ); + } else { + return $s; + } + } + + //Test options parser functions + + protected function parseOptions( $instring ) { + $opts = array(); + // foo + // foo=bar + // foo="bar baz" + // foo=[[bar baz]] + // foo=bar,"baz quux" + $regex = '/\b + ([\w-]+) # Key + \b + (?:\s* + = # First sub-value + \s* + ( + " + [^"]* # Quoted val + " + | + \[\[ + [^]]* # Link target + \]\] + | + [\w-]+ # Plain word + ) + (?:\s* + , # Sub-vals 1..N + \s* + ( + "[^"]*" # Quoted val + | + \[\[[^]]*\]\] # Link target + | + [\w-]+ # Plain word + ) + )* + )? + /x'; + + if ( preg_match_all( $regex, $instring, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $bits ) { + array_shift( $bits ); + $key = strtolower( array_shift( $bits ) ); + if ( count( $bits ) == 0 ) { + $opts[$key] = true; + } elseif ( count( $bits ) == 1 ) { + $opts[$key] = $this->cleanupOption( array_shift( $bits ) ); + } else { + // Array! + $opts[$key] = array_map( array( $this, 'cleanupOption' ), $bits ); + } + } + } + + return $opts; + } + + protected function cleanupOption( $opt ) { + if ( substr( $opt, 0, 1 ) == '"' ) { + return substr( $opt, 1, -1 ); + } + + if ( substr( $opt, 0, 2 ) == '[[' ) { + return substr( $opt, 2, -2 ); + } + + return $opt; + } + + /** + * Use a regex to find out the value of an option + * @param string $key Name of option val to retrieve + * @param array $opts Options array to look in + * @param mixed $default Default value returned if not found + * @return mixed + */ + protected static function getOptionValue( $key, $opts, $default ) { + $key = strtolower( $key ); + + if ( isset( $opts[$key] ) ) { + return $opts[$key]; + } else { + return $default; + } + } +} diff --git a/tests/phpunit/includes/parser/ParserMethodsTest.php b/tests/phpunit/includes/parser/ParserMethodsTest.php new file mode 100644 index 00000000..1790086a --- /dev/null +++ b/tests/phpunit/includes/parser/ParserMethodsTest.php @@ -0,0 +1,187 @@ +<?php + +class ParserMethodsTest extends MediaWikiLangTestCase { + + public static function providePreSaveTransform() { + return array( + array( 'hello this is ~~~', + "hello this is [[Special:Contributions/127.0.0.1|127.0.0.1]]", + ), + array( 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + 'hello \'\'this\'\' is <nowiki>~~~</nowiki>', + ), + ); + } + + /** + * @dataProvider providePreSaveTransform + * @covers Parser::preSaveTransform + */ + public function testPreSaveTransform( $text, $expected ) { + global $wgParser; + + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $user = new User(); + $user->setName( "127.0.0.1" ); + $popts = ParserOptions::newFromUser( $user ); + $text = $wgParser->preSaveTransform( $text, $title, $user, $popts ); + + $this->assertEquals( $expected, $text ); + } + + public static function provideStripOuterParagraph() { + // This mimics the most common use case (stripping paragraphs generated by the parser). + $message = new RawMessage( "Message text." ); + + return array( + array( + "<p>Text.</p>", + "Text.", + ), + array( + "<p class='foo'>Text.</p>", + "<p class='foo'>Text.</p>", + ), + array( + "<p>Text.\n</p>\n", + "Text.", + ), + array( + "<p>Text.</p><p>More text.</p>", + "<p>Text.</p><p>More text.</p>", + ), + array( + $message->parse(), + "Message text.", + ), + ); + } + + /** + * @dataProvider provideStripOuterParagraph + * @covers Parser::stripOuterParagraph + */ + public function testStripOuterParagraph( $text, $expected ) { + $this->assertEquals( $expected, Parser::stripOuterParagraph( $text ) ); + } + + /** + * @expectedException MWException + * @expectedExceptionMessage Parser state cleared while parsing. Did you call Parser::parse recursively? + * @covers Parser::lock + */ + public function testRecursiveParse() { + global $wgParser; + $title = Title::newFromText( 'foo' ); + $po = new ParserOptions; + $wgParser->setHook( 'recursivecallparser', array( $this, 'helperParserFunc' ) ); + $wgParser->parse( '<recursivecallparser>baz</recursivecallparser>', $title, $po ); + } + + public function helperParserFunc( $input, $args, $parser ) { + $title = Title::newFromText( 'foo' ); + $po = new ParserOptions; + $parser->parse( $input, $title, $po ); + return 'bar'; + } + + /** + * @covers Parser::callParserFunction + */ + public function testCallParserFunction() { + global $wgParser; + + // Normal parses test passing PPNodes. Test passing an array. + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $wgParser->startExternalParse( $title, new ParserOptions(), Parser::OT_HTML ); + $frame = $wgParser->getPreprocessor()->newFrame(); + $ret = $wgParser->callParserFunction( $frame, '#tag', + array( 'pre', 'foo', 'style' => 'margin-left: 1.6em' ) + ); + $ret['text'] = $wgParser->mStripState->unstripBoth( $ret['text'] ); + $this->assertSame( array( + 'found' => true, + 'text' => '<pre style="margin-left: 1.6em">foo</pre>', + ), $ret, 'callParserFunction works for {{#tag:pre|foo|style=margin-left: 1.6em}}' ); + } + + /** + * @covers Parser::parse + * @covers ParserOutput::getSections + */ + public function testGetSections() { + global $wgParser; + + $title = Title::newFromText( str_replace( '::', '__', __METHOD__ ) ); + $out = $wgParser->parse( "==foo==\n<h2>bar</h2>\n==baz==\n", $title, new ParserOptions() ); + $this->assertSame( array( + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'foo', + 'number' => '1', + 'index' => '1', + 'fromtitle' => $title->getPrefixedDBkey(), + 'byteoffset' => 0, + 'anchor' => 'foo', + ), + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'bar', + 'number' => '2', + 'index' => '', + 'fromtitle' => false, + 'byteoffset' => null, + 'anchor' => 'bar', + ), + array( + 'toclevel' => 1, + 'level' => '2', + 'line' => 'baz', + 'number' => '3', + 'index' => '2', + 'fromtitle' => $title->getPrefixedDBkey(), + 'byteoffset' => 21, + 'anchor' => 'baz', + ), + ), $out->getSections(), 'getSections() with proper value when <h2> is used' ); + } + + /** + * @dataProvider provideNormalizeLinkUrl + * @covers Parser::normalizeLinkUrl + * @covers Parser::normalizeUrlComponent + */ + public function testNormalizeLinkUrl( $explanation, $url, $expected ) { + $this->assertEquals( $expected, Parser::normalizeLinkUrl( $url ), $explanation ); + } + + public static function provideNormalizeLinkUrl() { + return array( + array( + 'Escaping of unsafe characters', + 'http://example.org/foo bar?param[]="value"¶m[]=valüe', + 'http://example.org/foo%20bar?param%5B%5D=%22value%22¶m%5B%5D=val%C3%BCe', + ), + array( + 'Case normalization of percent-encoded characters', + 'http://example.org/%ab%cD%Ef%FF', + 'http://example.org/%AB%CD%EF%FF', + ), + array( + 'Unescaping of safe characters', + 'http://example.org/%3C%66%6f%6F%3E?%3C%66%6f%6F%3E#%3C%66%6f%6F%3E', + 'http://example.org/%3Cfoo%3E?%3Cfoo%3E#%3Cfoo%3E', + ), + array( + 'Context-sensitive replacement of sometimes-safe characters', + 'http://example.org/%23%2F%3F%26%3D%2B%3B?%23%2F%3F%26%3D%2B%3B#%23%2F%3F%26%3D%2B%3B', + 'http://example.org/%23%2F%3F&=+;?%23/?%26%3D%2B%3B#%23/?&=+;', + ), + ); + } + + // @todo Add tests for cleanSig() / cleanSigInSig(), getSection(), + // replaceSection(), getPreloadText() +} diff --git a/tests/phpunit/includes/parser/ParserOutputTest.php b/tests/phpunit/includes/parser/ParserOutputTest.php new file mode 100644 index 00000000..c024cee5 --- /dev/null +++ b/tests/phpunit/includes/parser/ParserOutputTest.php @@ -0,0 +1,87 @@ +<?php + +class ParserOutputTest extends MediaWikiTestCase { + + public static function provideIsLinkInternal() { + return array( + // Different domains + array( false, 'http://example.org', 'http://mediawiki.org' ), + // Same domains + array( true, 'http://example.org', 'http://example.org' ), + array( true, 'https://example.org', 'https://example.org' ), + array( true, '//example.org', '//example.org' ), + // Same domain different cases + array( true, 'http://example.org', 'http://EXAMPLE.ORG' ), + // Paths, queries, and fragments are not relevant + array( true, 'http://example.org', 'http://example.org/wiki/Main_Page' ), + array( true, 'http://example.org', 'http://example.org?my=query' ), + array( true, 'http://example.org', 'http://example.org#its-a-fragment' ), + // Different protocols + array( false, 'http://example.org', 'https://example.org' ), + array( false, 'https://example.org', 'http://example.org' ), + // Protocol relative servers always match http and https links + array( true, '//example.org', 'http://example.org' ), + array( true, '//example.org', 'https://example.org' ), + // But they don't match strange things like this + array( false, '//example.org', 'irc://example.org' ), + ); + } + + /** + * Test to make sure ParserOutput::isLinkInternal behaves properly + * @dataProvider provideIsLinkInternal + * @covers ParserOutput::isLinkInternal + */ + public function testIsLinkInternal( $shouldMatch, $server, $url ) { + $this->assertEquals( $shouldMatch, ParserOutput::isLinkInternal( $server, $url ) ); + } + + /** + * @covers ParserOutput::setExtensionData + * @covers ParserOutput::getExtensionData + */ + public function testExtensionData() { + $po = new ParserOutput(); + + $po->setExtensionData( "one", "Foo" ); + + $this->assertEquals( "Foo", $po->getExtensionData( "one" ) ); + $this->assertNull( $po->getExtensionData( "spam" ) ); + + $po->setExtensionData( "two", "Bar" ); + $this->assertEquals( "Foo", $po->getExtensionData( "one" ) ); + $this->assertEquals( "Bar", $po->getExtensionData( "two" ) ); + + $po->setExtensionData( "one", null ); + $this->assertNull( $po->getExtensionData( "one" ) ); + $this->assertEquals( "Bar", $po->getExtensionData( "two" ) ); + } + + /** + * @covers ParserOutput::setProperty + * @covers ParserOutput::getProperty + * @covers ParserOutput::unsetProperty + * @covers ParserOutput::getProperties + */ + public function testProperties() { + $po = new ParserOutput(); + + $po->setProperty( 'foo', 'val' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), 'val' ); + $this->assertEquals( $properties['foo'], 'val' ); + + $po->setProperty( 'foo', 'second val' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), 'second val' ); + $this->assertEquals( $properties['foo'], 'second val' ); + + $po->unsetProperty( 'foo' ); + + $properties = $po->getProperties(); + $this->assertEquals( $po->getProperty( 'foo' ), false ); + $this->assertArrayNotHasKey( 'foo', $properties ); + } +} diff --git a/tests/phpunit/includes/parser/ParserPreloadTest.php b/tests/phpunit/includes/parser/ParserPreloadTest.php new file mode 100644 index 00000000..d12fee36 --- /dev/null +++ b/tests/phpunit/includes/parser/ParserPreloadTest.php @@ -0,0 +1,80 @@ +<?php +/** + * Basic tests for Parser::getPreloadText + * @author Antoine Musso + */ +class ParserPreloadTest extends MediaWikiTestCase { + /** + * @var Parser + */ + private $testParser; + /** + * @var ParserOptions + */ + private $testParserOptions; + /** + * @var Title + */ + private $title; + + protected function setUp() { + global $wgContLang; + + parent::setUp(); + $this->testParserOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang ); + + $this->testParser = new Parser(); + $this->testParser->Options( $this->testParserOptions ); + $this->testParser->clearState(); + + $this->title = Title::newFromText( 'Preload Test' ); + } + + protected function tearDown() { + parent::tearDown(); + + unset( $this->testParser ); + unset( $this->title ); + } + + /** + * @covers Parser::getPreloadText + */ + public function testPreloadSimpleText() { + $this->assertPreloaded( 'simple', 'simple' ); + } + + /** + * @covers Parser::getPreloadText + */ + public function testPreloadedPreIsUnstripped() { + $this->assertPreloaded( + '<pre>monospaced</pre>', + '<pre>monospaced</pre>', + '<pre> in preloaded text must be unstripped (bug 27467)' + ); + } + + /** + * @covers Parser::getPreloadText + */ + public function testPreloadedNowikiIsUnstripped() { + $this->assertPreloaded( + '<nowiki>[[Dummy title]]</nowiki>', + '<nowiki>[[Dummy title]]</nowiki>', + '<nowiki> in preloaded text must be unstripped (bug 27467)' + ); + } + + protected function assertPreloaded( $expected, $text, $msg = '' ) { + $this->assertEquals( + $expected, + $this->testParser->getPreloadText( + $text, + $this->title, + $this->testParserOptions + ), + $msg + ); + } +} diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php new file mode 100644 index 00000000..345fd0a5 --- /dev/null +++ b/tests/phpunit/includes/parser/PreprocessorTest.php @@ -0,0 +1,247 @@ +<?php + +class PreprocessorTest extends MediaWikiTestCase { + protected $mTitle = 'Page title'; + protected $mPPNodeCount = 0; + /** + * @var ParserOptions + */ + protected $mOptions; + /** + * @var Preprocessor + */ + protected $mPreprocessor; + + protected function setUp() { + global $wgParserConf, $wgContLang; + parent::setUp(); + $this->mOptions = ParserOptions::newFromUserAndLang( new User, $wgContLang ); + $name = isset( $wgParserConf['preprocessorClass'] ) + ? $wgParserConf['preprocessorClass'] + : 'Preprocessor_DOM'; + + $this->mPreprocessor = new $name( $this ); + } + + function getStripList() { + return array( 'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo' ); + } + + public static function provideCases() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + return array( + array( "Foo", "<root>Foo</root>" ), + array( "<!-- Foo -->", "<root><comment><!-- Foo --></comment></root>" ), + array( "<!-- Foo --><!-- Bar -->", "<root><comment><!-- Foo --></comment><comment><!-- Bar --></comment></root>" ), + array( "<!-- Foo --> <!-- Bar -->", "<root><comment><!-- Foo --></comment> <comment><!-- Bar --></comment></root>" ), + array( "<!-- Foo --> \n <!-- Bar -->", "<root><comment><!-- Foo --></comment> \n <comment><!-- Bar --></comment></root>" ), + array( "<!-- Foo --> \n <!-- Bar -->\n", "<root><comment><!-- Foo --></comment> \n<comment> <!-- Bar -->\n</comment></root>" ), + array( "<!-- Foo --> <!-- Bar -->\n", "<root><comment><!-- Foo --></comment> <comment><!-- Bar --></comment>\n</root>" ), + array( "<!-->Bar", "<root><comment><!-->Bar</comment></root>" ), + array( "<!-- Comment -- comment", "<root><comment><!-- Comment -- comment</comment></root>" ), + array( "== Foo ==\n <!-- Bar -->\n== Baz ==\n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<comment> <!-- Bar -->\n</comment><h level=\"2\" i=\"2\">== Baz ==</h>\n</root>" ), + array( "<gallery/>", "<root><ext><name>gallery</name><attr></attr></ext></root>" ), + array( "Foo <gallery/> Bar", "<root>Foo <ext><name>gallery</name><attr></attr></ext> Bar</root>" ), + array( "<gallery></gallery>", "<root><ext><name>gallery</name><attr></attr><inner></inner><close></gallery></close></ext></root>" ), + array( "<foo> <gallery></gallery>", "<root><foo> <ext><name>gallery</name><attr></attr><inner></inner><close></gallery></close></ext></root>" ), + array( "<foo> <gallery><gallery></gallery>", "<root><foo> <ext><name>gallery</name><attr></attr><inner><gallery></inner><close></gallery></close></ext></root>" ), + array( "<noinclude> Foo bar </noinclude>", "<root><ignore><noinclude></ignore> Foo bar <ignore></noinclude></ignore></root>" ), + array( "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore><noinclude></ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore></noinclude></ignore></root>" ), + array( "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore><noinclude></ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore></noinclude></ignore>\n</root>" ), + array( "<gallery>foo bar", "<root><ext><name>gallery</name><attr></attr><inner>foo bar</inner></ext></root>" ), + array( "<{{foo}}>", "<root><<template><title>foo</title></template>></root>" ), + array( "<{{{foo}}}>", "<root><<tplarg><title>foo</title></tplarg>></root>" ), + array( "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner></gallery</inner><close></gallery></close></ext></root>" ), + array( "=== Foo === ", "<root><h level=\"3\" i=\"1\">=== Foo === </h></root>" ), + array( "==<!-- -->= Foo === ", "<root><h level=\"2\" i=\"1\">==<comment><!-- --></comment>= Foo === </h></root>" ), + array( "=== Foo ==<!-- -->= ", "<root><h level=\"1\" i=\"1\">=== Foo ==<comment><!-- --></comment>= </h></root>" ), + array( "=== Foo ===<!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment><!-- --></comment></h>\n</root>" ), + array( "=== Foo ===<!-- --> <!-- -->\n", "<root><h level=\"3\" i=\"1\">=== Foo ===<comment><!-- --></comment> <comment><!-- --></comment></h>\n</root>" ), + array( "== Foo ==\n== Bar == \n", "<root><h level=\"2\" i=\"1\">== Foo ==</h>\n<h level=\"2\" i=\"2\">== Bar == </h>\n</root>" ), + array( "===========", "<root><h level=\"5\" i=\"1\">===========</h></root>" ), + array( "Foo\n=\n==\n=\n", "<root>Foo\n=\n==\n=\n</root>" ), + array( "{{Foo}}", "<root><template><title>Foo</title></template></root>" ), + array( "\n{{Foo}}", "<root>\n<template lineStart=\"1\"><title>Foo</title></template></root>" ), + array( "{{Foo|bar}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template></root>" ), + array( "{{Foo|bar}}a", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part></template>a</root>" ), + array( "{{Foo|bar|baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></template></root>" ), + array( "{{Foo|1=bar}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part></template></root>" ), + array( "{{Foo|=bar}}", "<root><template><title>Foo</title><part><name></name>=<value>bar</value></part></template></root>" ), + array( "{{Foo|bar=baz}}", "<root><template><title>Foo</title><part><name>bar</name>=<value>baz</value></part></template></root>" ), + array( "{{Foo|{{bar}}=baz}}", "<root><template><title>Foo</title><part><name><template><title>bar</title></template></name>=<value>baz</value></part></template></root>" ), + array( "{{Foo|1=bar|baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name index=\"1\" /><value>baz</value></part></template></root>" ), + array( "{{Foo|1=bar|2=baz}}", "<root><template><title>Foo</title><part><name>1</name>=<value>bar</value></part><part><name>2</name>=<value>baz</value></part></template></root>" ), + array( "{{Foo|bar|foo=baz}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name>foo</name>=<value>baz</value></part></template></root>" ), + array( "{{{1}}}", "<root><tplarg><title>1</title></tplarg></root>" ), + array( "{{{1|}}}", "<root><tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ), + array( "{{{Foo}}}", "<root><tplarg><title>Foo</title></tplarg></root>" ), + array( "{{{Foo|}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value></value></part></tplarg></root>" ), + array( "{{{Foo|bar|baz}}}", "<root><tplarg><title>Foo</title><part><name index=\"1\" /><value>bar</value></part><part><name index=\"2\" /><value>baz</value></part></tplarg></root>" ), + array( "{<!-- -->{Foo}}", "<root>{<comment><!-- --></comment>{Foo}}</root>" ), + array( "{{{{Foobar}}}}", "<root>{<tplarg><title>Foobar</title></tplarg>}</root>" ), + array( "{{{ {{Foo}} }}}", "<root><tplarg><title> <template><title>Foo</title></template> </title></tplarg></root>" ), + array( "{{ {{{Foo}}} }}", "<root><template><title> <tplarg><title>Foo</title></tplarg> </title></template></root>" ), + array( "{{{{{Foo}}}}}", "<root><template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ), + array( "{{{{{Foo}} }}}", "<root><tplarg><title><template><title>Foo</title></template> </title></tplarg></root>" ), + array( "{{{{{{Foo}}}}}}", "<root><tplarg><title><tplarg><title>Foo</title></tplarg></title></tplarg></root>" ), + array( "{{{{{{Foo}}}}}", "<root>{<template><title><tplarg><title>Foo</title></tplarg></title></template></root>" ), + array( "[[[Foo]]", "<root>[[[Foo]]</root>" ), + array( "{{Foo|[[[[bar]]|baz]]}}", "<root><template><title>Foo</title><part><name index=\"1\" /><value>[[[[bar]]|baz]]</value></part></template></root>" ), // This test is important, since it means the difference between having the [[ rule stacked or not + array( "{{Foo|[[[[bar]|baz]]}}", "<root>{{Foo|[[[[bar]|baz]]}}</root>" ), + array( "{{Foo|Foo [[[[bar]|baz]]}}", "<root>{{Foo|Foo [[[[bar]|baz]]}}</root>" ), + array( "Foo <display map>Bar</display map >Baz", "<root>Foo <ext><name>display map</name><attr></attr><inner>Bar</inner><close></display map ></close></ext>Baz</root>" ), + array( "Foo <display map foo>Bar</display map >Baz", "<root>Foo <ext><name>display map</name><attr> foo</attr><inner>Bar</inner><close></display map ></close></ext>Baz</root>" ), + array( "Foo <gallery bar=\"baz\" />", "<root>Foo <ext><name>gallery</name><attr> bar="baz" </attr></ext></root>" ), + array( "Foo <gallery bar=\"1\" baz=2 />", "<root>Foo <ext><name>gallery</name><attr> bar="1" baz=2 </attr></ext></root>" ), + array( "</foo>Foo<//foo>", "<root><ext><name>/foo</name><attr></attr><inner>Foo</inner><close><//foo></close></ext></root>" ), # Worth blacklisting IMHO + array( "{{#ifexpr: ({{{1|1}}} = 2) | Foo | Bar }}", "<root><template><title>#ifexpr: (<tplarg><title>1</title><part><name index=\"1\" /><value>1</value></part></tplarg> = 2) </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ), + array( "{{#if: {{{1|}}} | Foo | {{Bar}} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> <template><title>Bar</title></template> </value></part></template></root>" ), + array( "{{#if: {{{1|}}} | Foo | [[Bar]] }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> Foo </value></part><part><name index=\"2\" /><value> [[Bar]] </value></part></template></root>" ), + array( "{{#if: {{{1|}}} | [[Foo]] | Bar }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> [[Foo]] </value></part><part><name index=\"2\" /><value> Bar </value></part></template></root>" ), + array( "{{#if: {{{1|}}} | 1 | {{#if: {{{1|}}} | 2 | 3 }} }}", "<root><template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 1 </value></part><part><name index=\"2\" /><value> <template><title>#if: <tplarg><title>1</title><part><name index=\"1\" /><value></value></part></tplarg> </title><part><name index=\"1\" /><value> 2 </value></part><part><name index=\"2\" /><value> 3 </value></part></template> </value></part></template></root>" ), + array( "{{ {{Foo}}", "<root>{{ <template><title>Foo</title></template></root>" ), + array( "{{Foobar {{Foo}} {{Bar}} {{Baz}} ", "<root>{{Foobar <template><title>Foo</title></template> <template><title>Bar</title></template> <template><title>Baz</title></template> </root>" ), + array( "[[Foo]] |", "<root>[[Foo]] |</root>" ), + array( "{{Foo|Bar|", "<root>{{Foo|Bar|</root>" ), + array( "[[Foo]", "<root>[[Foo]</root>" ), + array( "[[Foo|Bar]", "<root>[[Foo|Bar]</root>" ), + array( "{{Foo| [[Bar] }}", "<root>{{Foo| [[Bar] }}</root>" ), + array( "{{Foo| [[Bar|Baz] }}", "<root>{{Foo| [[Bar|Baz] }}</root>" ), + array( "{{Foo|bar=[[baz]}}", "<root>{{Foo|bar=[[baz]}}</root>" ), + array( "{{foo|", "<root>{{foo|</root>" ), + array( "{{foo|}", "<root>{{foo|}</root>" ), + array( "{{foo|} }}", "<root><template><title>foo</title><part><name index=\"1\" /><value>} </value></part></template></root>" ), + array( "{{foo|bar=|}", "<root>{{foo|bar=|}</root>" ), + array( "{{Foo|} Bar=", "<root>{{Foo|} Bar=</root>" ), + array( "{{Foo|} Bar=}}", "<root><template><title>Foo</title><part><name>} Bar</name>=<value></value></part></template></root>" ), + /* array( file_get_contents( __DIR__ . '/QuoteQuran.txt' ), file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ), */ + ); + // @codingStandardsIgnoreEnd + } + + /** + * Get XML preprocessor tree from the preprocessor (which may not be the + * native XML-based one). + * + * @param string $wikiText + * @return string + */ + protected function preprocessToXml( $wikiText ) { + if ( method_exists( $this->mPreprocessor, 'preprocessToXml' ) ) { + return $this->normalizeXml( $this->mPreprocessor->preprocessToXml( $wikiText ) ); + } + + $dom = $this->mPreprocessor->preprocessToObj( $wikiText ); + if ( is_callable( array( $dom, 'saveXML' ) ) ) { + return $dom->saveXML(); + } else { + return $this->normalizeXml( $dom->__toString() ); + } + } + + /** + * Normalize XML string to the form that a DOMDocument saves out. + * + * @param string $xml + * @return string + */ + protected function normalizeXml( $xml ) { + return preg_replace( '!<([a-z]+)/>!', '<$1></$1>', str_replace( ' />', '/>', $xml ) ); + } + + /** + * @dataProvider provideCases + * @covers Preprocessor_DOM::preprocessToXml + */ + public function testPreprocessorOutput( $wikiText, $expectedXml ) { + $this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) ); + } + + /** + * These are more complex test cases taken out of wiki articles. + */ + public static function provideFiles() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + return array( + array( "QuoteQuran" ), # http://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver + array( "Factorial" ), # http://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium + array( "All_system_messages" ), # http://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki + array( "Fundraising" ), # http://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor. + array( "NestedTemplates" ), # bug 27936 + ); + // @codingStandardsIgnoreEnd + } + + /** + * @dataProvider provideFiles + * @covers Preprocessor_DOM::preprocessToXml + */ + public function testPreprocessorOutputFiles( $filename ) { + $folder = __DIR__ . "/../../../parser/preprocess"; + $wikiText = file_get_contents( "$folder/$filename.txt" ); + $output = $this->preprocessToXml( $wikiText ); + + $expectedFilename = "$folder/$filename.expected"; + if ( file_exists( $expectedFilename ) ) { + $expectedXml = $this->normalizeXml( file_get_contents( $expectedFilename ) ); + $this->assertEquals( $expectedXml, $output ); + } else { + $tempFilename = tempnam( $folder, "$filename." ); + file_put_contents( $tempFilename, $output ); + $this->markTestIncomplete( "File $expectedFilename missing. Output stored as $tempFilename" ); + } + } + + /** + * Tests from Bug 28642 · https://bugzilla.wikimedia.org/28642 + */ + public static function provideHeadings() { + // @codingStandardsIgnoreStart Ignore Generic.Files.LineLength.TooLong + return array( /* These should become headings: */ + array( "== h ==<!--c1-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment></h></root>" ), + array( "== h == <!--c1-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment></h></root>" ), + array( "== h ==<!--c1--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> </h></root>" ), + array( "== h == <!--c1--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> </h></root>" ), + array( "== h ==<!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment></h></root>" ), + array( "== h == <!--c1--><!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment></h></root>" ), + array( "== h ==<!--c1--><!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment> </h></root>" ), + array( "== h == <!--c1--><!--c2--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment> </h></root>" ), + array( "== h == <!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment></h></root>" ), + array( "== h ==<!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment> </h></root>" ), + array( "== h == <!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment> </h></root>" ), + array( "== h ==<!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment></h></root>" ), + array( "== h ==<!--c1--> <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment><comment><!--c3--></comment></h></root>" ), + array( "== h ==<!--c1--><!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment> <comment><!--c3--></comment></h></root>" ), + array( "== h ==<!--c1--> <!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment> <comment><!--c3--></comment></h></root>" ), + array( "== h == <!--c1--><!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment></h></root>" ), + array( "== h == <!--c1--> <!--c2--><!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment><comment><!--c3--></comment></h></root>" ), + array( "== h == <!--c1--><!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment> <comment><!--c3--></comment></h></root>" ), + array( "== h == <!--c1--> <!--c2--> <!--c3-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment> <comment><!--c3--></comment></h></root>" ), + array( "== h ==<!--c1--><!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment> </h></root>" ), + array( "== h ==<!--c1--> <!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment><comment><!--c3--></comment> </h></root>" ), + array( "== h ==<!--c1--><!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment><comment><!--c2--></comment> <comment><!--c3--></comment> </h></root>" ), + array( "== h ==<!--c1--> <!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment> <comment><!--c3--></comment> </h></root>" ), + array( "== h == <!--c1--><!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment> </h></root>" ), + array( "== h == <!--c1--> <!--c2--><!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment><comment><!--c3--></comment> </h></root>" ), + array( "== h == <!--c1--><!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment><comment><!--c2--></comment> <comment><!--c3--></comment> </h></root>" ), + array( "== h == <!--c1--> <!--c2--> <!--c3--> ", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment> <comment><!--c3--></comment> </h></root>" ), + array( "== h ==<!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment></h></root>" ), + array( "== h == <!--c1--> <!--c2-->", "<root><h level=\"2\" i=\"1\">== h == <comment><!--c1--></comment> <comment><!--c2--></comment></h></root>" ), + array( "== h ==<!--c1--> <!--c2--> ", "<root><h level=\"2\" i=\"1\">== h ==<comment><!--c1--></comment> <comment><!--c2--></comment> </h></root>" ), + + /* These are not working: */ + array( "== h == x <!--c1--><!--c2--><!--c3--> ", "<root>== h == x <comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment> </root>" ), + array( "== h ==<!--c1--> x <!--c2--><!--c3--> ", "<root>== h ==<comment><!--c1--></comment> x <comment><!--c2--></comment><comment><!--c3--></comment> </root>" ), + array( "== h ==<!--c1--><!--c2--><!--c3--> x ", "<root>== h ==<comment><!--c1--></comment><comment><!--c2--></comment><comment><!--c3--></comment> x </root>" ), + ); + // @codingStandardsIgnoreEnd + } + + /** + * @dataProvider provideHeadings + * @covers Preprocessor_DOM::preprocessToXml + */ + public function testHeadings( $wikiText, $expectedXml ) { + $this->assertEquals( $this->normalizeXml( $expectedXml ), $this->preprocessToXml( $wikiText ) ); + } +} diff --git a/tests/phpunit/includes/parser/TagHooksTest.php b/tests/phpunit/includes/parser/TagHooksTest.php new file mode 100644 index 00000000..e3c4cc84 --- /dev/null +++ b/tests/phpunit/includes/parser/TagHooksTest.php @@ -0,0 +1,108 @@ +<?php + +/** + * @group Parser + */ +class TagHookTest extends MediaWikiTestCase { + public static function provideValidNames() { + return array( + array( 'foo' ), + array( 'foo-bar' ), + array( 'foo_bar' ), + array( 'FOO-BAR' ), + array( 'foo bar' ) + ); + } + + public static function provideBadNames() { + return array( array( "foo<bar" ), array( "foo>bar" ), array( "foo\nbar" ), array( "foo\rbar" ) ); + } + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( 'wgAlwaysUseTidy', false ); + } + + /** + * @dataProvider provideValidNames + * @covers Parser::setHook + */ + public function testTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setHook( $tag, array( $this, 'tagCallback' ) ); + $parserOutput = $parser->parse( + "Foo<$tag>Bar</$tag>Baz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->assertEquals( "<p>FooOneBaz\n</p>", $parserOutput->getText() ); + + $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle + } + + /** + * @dataProvider provideBadNames + * @expectedException MWException + * @covers Parser::setHook + */ + public function testBadTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setHook( $tag, array( $this, 'tagCallback' ) ); + $parser->parse( + "Foo<$tag>Bar</$tag>Baz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->fail( 'Exception not thrown.' ); + } + + /** + * @dataProvider provideValidNames + * @covers Parser::setFunctionTagHook + */ + public function testFunctionTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setFunctionTagHook( $tag, array( $this, 'functionTagCallback' ), 0 ); + $parserOutput = $parser->parse( + "Foo<$tag>Bar</$tag>Baz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->assertEquals( "<p>FooOneBaz\n</p>", $parserOutput->getText() ); + + $parser->mPreprocessor = null; # Break the Parser <-> Preprocessor cycle + } + + /** + * @dataProvider provideBadNames + * @expectedException MWException + * @covers Parser::setFunctionTagHook + */ + public function testBadFunctionTagHooks( $tag ) { + global $wgParserConf, $wgContLang; + $parser = new Parser( $wgParserConf ); + + $parser->setFunctionTagHook( $tag, array( $this, 'functionTagCallback' ), SFH_OBJECT_ARGS ); + $parser->parse( + "Foo<$tag>Bar</$tag>Baz", + Title::newFromText( 'Test' ), + ParserOptions::newFromUserAndLang( new User, $wgContLang ) + ); + $this->fail( 'Exception not thrown.' ); + } + + function tagCallback( $text, $params, $parser ) { + return str_rot13( $text ); + } + + function functionTagCallback( &$parser, $frame, $code, $attribs ) { + return str_rot13( $code ); + } +} diff --git a/tests/phpunit/includes/parser/TidyTest.php b/tests/phpunit/includes/parser/TidyTest.php new file mode 100644 index 00000000..f656a74d --- /dev/null +++ b/tests/phpunit/includes/parser/TidyTest.php @@ -0,0 +1,64 @@ +<?php + +/** + * @group Parser + */ +class TidyTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + $check = MWTidy::tidy( '' ); + if ( strpos( $check, '<!--' ) !== false ) { + $this->markTestSkipped( 'Tidy not found' ); + } + } + + /** + * @dataProvider provideTestWrapping + */ + public function testTidyWrapping( $expected, $text, $msg = '' ) { + $text = MWTidy::tidy( $text ); + // We don't care about where Tidy wants to stick is <p>s + $text = trim( preg_replace( '#</?p>#', '', $text ) ); + // Windows, we love you! + $text = str_replace( "\r", '', $text ); + $this->assertEquals( $expected, $text, $msg ); + } + + public static function provideTestWrapping() { + $testMathML = <<<'MathML' +<math xmlns="http://www.w3.org/1998/Math/MathML"> + <mrow> + <mi>a</mi> + <mo>⁢</mo> + <msup> + <mi>x</mi> + <mn>2</mn> + </msup> + <mo>+</mo> + <mi>b</mi> + <mo>⁢ </mo> + <mi>x</mi> + <mo>+</mo> + <mi>c</mi> + </mrow> + </math> +MathML; + return array( + array( + '<mw:editsection page="foo" section="bar">foo</mw:editsection>', + '<mw:editsection page="foo" section="bar">foo</mw:editsection>', + '<mw:editsection> should survive tidy' + ), + array( + '<editsection page="foo" section="bar">foo</editsection>', + '<editsection page="foo" section="bar">foo</editsection>', + '<editsection> should survive tidy' + ), + array( '<mw:toc>foo</mw:toc>', '<mw:toc>foo</mw:toc>', '<mw:toc> should survive tidy' ), + array( "<link foo=\"bar\" />\nfoo", '<link foo="bar"/>foo', '<link> should survive tidy' ), + array( "<meta foo=\"bar\" />\nfoo", '<meta foo="bar"/>foo', '<meta> should survive tidy' ), + array( $testMathML, $testMathML, '<math> should survive tidy' ), + ); + } +} diff --git a/tests/phpunit/includes/password/BcryptPasswordTest.php b/tests/phpunit/includes/password/BcryptPasswordTest.php new file mode 100644 index 00000000..8ac419ff --- /dev/null +++ b/tests/phpunit/includes/password/BcryptPasswordTest.php @@ -0,0 +1,40 @@ +<?php + +/** + * @group large + */ +class BcryptPasswordTestCase extends PasswordTestCase { + protected function getTypeConfigs() { + return array( 'bcrypt' => array( + 'class' => 'BcryptPassword', + 'cost' => 9, + ) ); + } + + public static function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + // Tests from glibc bcrypt implementation + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "U*U" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$VGOzA784oUp/Z0DY336zx7pLYAy0lwK', "U*U*" ), + array( true, ':bcrypt:5$XXXXXXXXXXXXXXXXXXXXXO$AcXxm9kjPGEMsLznoKqmqw7tc8WCx4a', "U*U*U" ), + array( true, ':bcrypt:5$abcdefghijklmnopqrstuu$5s2v8.iXieOjg/.AySBTTZIIVFJeBui', "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$CE5elHaaO4EbggVDjb8P19RukzXSM3e', "\xff\xff\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$Sa7shbm4.OzKpvFnX1pQLmQW96oUlCq', "\xa3" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$o./n25XVfn6oAPaUvHe.Csk4zRfsYPi', "\xff\xa334\xff\xff\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$nRht2l/HRhr6zmCp9vYUvvsqynflf9e', "\xff\xa3345" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$6IflQkJytoRVc1yuaNtHfiuq.FRlSIS', "\xa3ab" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$swQOIzjOiJ9GHEPuhEkvqrUyvWhEMx6', "\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaachars after 72 are ignored as usual" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$R9xrDjiycxMbQE2bp.vgqlYpW5wx2yy', "\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55\xaa\x55" ), + array( true, ':bcrypt:5$/OK.fbVrR/bpIqNJ5ianF.$9tQZzcJfm3uj2NvJ/n5xkhpqLrMpWCe', "\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff\x55\xaa\xff" ), + array( true, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$7uG0VCzI2bS7j6ymqJi9CdcdxiRTWNy', "" ), + // One or two false sanity tests + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "UXU" ), + array( false, ':bcrypt:5$CCCCCCCCCCCCCCCCCCCCC.$E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW', "" ), + ); + /** @codingStandardsIgnoreEnd */ + } +} diff --git a/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php new file mode 100644 index 00000000..86e8270a --- /dev/null +++ b/tests/phpunit/includes/password/LayeredParameterizedPasswordTest.php @@ -0,0 +1,51 @@ +<?php + +class LayeredParameterizedPasswordTest extends PasswordTestCase { + protected function getTypeConfigs() { + return array( + 'testLargeLayeredTop' => array( + 'class' => 'LayeredParameterizedPassword', + 'types' => array( + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredBottom', + 'testLargeLayeredFinal', + ), + ), + 'testLargeLayeredBottom' => array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha512', + 'cost' => 1024, + 'length' => 512, + ), + 'testLargeLayeredFinal' => array( + 'class' => 'BcryptPassword', + 'cost' => 5, + ) + ); + } + + public static function providePasswordTests() { + /** @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong */ + return array( + array( true, ':testLargeLayeredTop:sha512:1024:512!sha512:1024:512!sha512:1024:512!sha512:1024:512!5!vnRy+2SrSA0fHt3dwhTP5g==!AVnwfZsAQjn+gULv7FSGjA==!xvHUX3WcpkeSn1lvjWcvBg==!It+OC/N9tu+d3ByHhuB0BQ==!Tb.gqUOiD.aWktVwHM.Q/O!7CcyMfXUPky5ptyATJsR2nq3vUqtnBC', 'testPassword123' ), + ); + /** @codingStandardsIgnoreEnd */ + } + + /** + * @covers LayeredParameterizedPassword::partialCrypt + */ + public function testLargeLayeredPartialUpdate() { + /** @var ParameterizedPassword $partialPassword */ + $partialPassword = $this->passwordFactory->newFromType( 'testLargeLayeredBottom' ); + $partialPassword->crypt( 'testPassword123' ); + + /** @var LayeredParameterizedPassword $totalPassword */ + $totalPassword = $this->passwordFactory->newFromType( 'testLargeLayeredTop' ); + $totalPassword->partialCrypt( $partialPassword ); + + $this->assertTrue( $totalPassword->equals( 'testPassword123' ) ); + } +} diff --git a/tests/phpunit/includes/password/PasswordTestCase.php b/tests/phpunit/includes/password/PasswordTestCase.php new file mode 100644 index 00000000..ef16f1c4 --- /dev/null +++ b/tests/phpunit/includes/password/PasswordTestCase.php @@ -0,0 +1,88 @@ +<?php +/** + * Testing framework for the password hashes + * + * 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 + */ + +/** + * @since 1.24 + */ +abstract class PasswordTestCase extends MediaWikiTestCase { + /** + * @var PasswordFactory + */ + protected $passwordFactory; + + protected function setUp() { + parent::setUp(); + + $this->passwordFactory = new PasswordFactory(); + foreach ( $this->getTypeConfigs() as $type => $config ) { + $this->passwordFactory->register( $type, $config ); + } + } + + /** + * Return an array of configs to be used for this class's password type. + * + * @return array[] + */ + abstract protected function getTypeConfigs(); + + /** + * An array of tests in the form of (bool, string, string), where the first + * element is whether the second parameter (a password hash) and the third + * parameter (a password) should match. + * + * @return array + */ + abstract public static function providePasswordTests(); + + /** + * @dataProvider providePasswordTests + */ + public function testHashing( $shouldMatch, $hash, $password ) { + $hash = $this->passwordFactory->newFromCiphertext( $hash ); + $password = $this->passwordFactory->newFromPlaintext( $password, $hash ); + $this->assertSame( $shouldMatch, $hash->equals( $password ) ); + } + + /** + * @dataProvider providePasswordTests + */ + public function testStringSerialization( $shouldMatch, $hash, $password ) { + $hashObj = $this->passwordFactory->newFromCiphertext( $hash ); + $serialized = $hashObj->toString(); + $unserialized = $this->passwordFactory->newFromCiphertext( $serialized ); + $this->assertTrue( $hashObj->equals( $unserialized ) ); + } + + /** + * @dataProvider providePasswordTests + * @covers InvalidPassword::equals + * @covers InvalidPassword::toString + */ + public function testInvalidUnequalNormal( $shouldMatch, $hash, $password ) { + $invalid = $this->passwordFactory->newFromCiphertext( null ); + $normal = $this->passwordFactory->newFromCiphertext( $hash ); + + $this->assertFalse( $invalid->equals( $normal ) ); + $this->assertFalse( $normal->equals( $invalid ) ); + } +} diff --git a/tests/phpunit/includes/password/Pbkdf2PasswordTest.php b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php new file mode 100644 index 00000000..091853e1 --- /dev/null +++ b/tests/phpunit/includes/password/Pbkdf2PasswordTest.php @@ -0,0 +1,24 @@ +<?php + +/** + * @group large + */ +class Pbkdf2PasswordTest extends PasswordTestCase { + protected function getTypeConfigs() { + return array( 'pbkdf2' => array( + 'class' => 'Pbkdf2Password', + 'algo' => 'sha256', + 'cost' => '10000', + 'length' => '128', + ) ); + } + + public static function providePasswordTests() { + return array( + array( true, ":pbkdf2:sha1:1:20:c2FsdA==:DGDID5YfDnHzqbUkr2ASBi/gN6Y=", 'password' ), + array( true, ":pbkdf2:sha1:2:20:c2FsdA==:6mwBTcctb4zNHtkqzh1B8NjeiVc=", 'password' ), + array( true, ":pbkdf2:sha1:4096:20:c2FsdA==:SwB5AbdlSJq+rUnZJvch0GWkKcE=", 'password' ), + array( true, ":pbkdf2:sha1:4096:16:c2EAbHQ=:Vvpqp1VICZ3MN9fwNCXgww==", "pass\x00word" ), + ); + } +} diff --git a/tests/phpunit/includes/poolcounter/PoolCounterTest.php b/tests/phpunit/includes/poolcounter/PoolCounterTest.php new file mode 100644 index 00000000..019e532c --- /dev/null +++ b/tests/phpunit/includes/poolcounter/PoolCounterTest.php @@ -0,0 +1,72 @@ +<?php + +// We will use this class with getMockForAbstractClass to create a concrete mock class. +// That call will die if the contructor is not public, unless we use disableOriginalConstructor(), +// in which case we could not test the constructor. +abstract class PoolCounterAbstractMock extends PoolCounter { + public function __construct() { + call_user_func_array( 'parent::__construct', func_get_args() ); + } +} + +class PoolCounterTest extends MediaWikiTestCase { + public function testConstruct() { + $poolCounterConfig = array( + 'class' => 'PoolCounterMock', + 'timeout' => 10, + 'workers' => 10, + 'maxqueue' => 100, + ); + + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + ->setConstructorArgs( array( $poolCounterConfig, 'testCounter', 'someKey' ) ) + // don't mock anything - the proper syntax would be setMethods(null), but due + // to a PHPUnit bug that does not work with getMockForAbstractClass() + ->setMethods( array( 'idontexist' ) ) + ->getMockForAbstractClass(); + $this->assertInstanceOf( 'PoolCounter', $poolCounter ); + } + + public function testConstructWithSlots() { + $poolCounterConfig = array( + 'class' => 'PoolCounterMock', + 'timeout' => 10, + 'workers' => 10, + 'slots' => 2, + 'maxqueue' => 100, + ); + + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + ->setConstructorArgs( array( $poolCounterConfig, 'testCounter', 'key' ) ) + ->setMethods( array( 'idontexist' ) ) // don't mock anything + ->getMockForAbstractClass(); + $this->assertInstanceOf( 'PoolCounter', $poolCounter ); + } + + public function testHashKeyIntoSlots() { + $poolCounter = $this->getMockBuilder( 'PoolCounterAbstractMock' ) + // don't mock anything - the proper syntax would be setMethods(null), but due + // to a PHPUnit bug that does not work with getMockForAbstractClass() + ->setMethods( array( 'idontexist' ) ) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $hashKeyIntoSlots = new ReflectionMethod( $poolCounter, 'hashKeyIntoSlots' ); + $hashKeyIntoSlots->setAccessible( true ); + + $keysWithTwoSlots = $keysWithFiveSlots = array(); + foreach ( range( 1, 100 ) as $i ) { + $keysWithTwoSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'key ' . $i, 2 ); + $keysWithFiveSlots[] = $hashKeyIntoSlots->invoke( $poolCounter, 'key ' . $i, 5 ); + } + + $this->assertArrayEquals( range( 0, 1 ), array_unique( $keysWithTwoSlots ) ); + $this->assertArrayEquals( range( 0, 4 ), array_unique( $keysWithFiveSlots ) ); + + // make sure it is deterministic + $this->assertEquals( + $hashKeyIntoSlots->invoke( $poolCounter, 'asdfgh', 1000 ), + $hashKeyIntoSlots->invoke( $poolCounter, 'asdfgh', 1000 ) + ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php new file mode 100644 index 00000000..b0edaaf7 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php @@ -0,0 +1,132 @@ +<?php + +class ResourceLoaderModuleTest extends ResourceLoaderTestCase { + + protected function setUp() { + parent::setUp(); + + // The return value of the closure shouldn't matter since this test should + // never call it + SkinFactory::getDefaultInstance()->register( + 'fakeskin', + 'FakeSkin', + function () { + } + ); + } + + /** + * @covers ResourceLoaderFileModule::getAllSkinStyleFiles + */ + public function testGetAllSkinStyleFiles() { + $context = self::getResourceLoaderContext(); + + $baseParams = array( + 'scripts' => array( + 'foo.js', + 'bar.js', + ), + 'styles' => array( + 'foo.css', + 'bar.css' => array( 'media' => 'print' ), + 'screen.less' => array( 'media' => 'screen' ), + 'screen-query.css' => array( 'media' => 'screen and (min-width: 400px)' ), + ), + 'skinStyles' => array( + 'default' => 'quux-fallback.less', + 'fakeskin' => array( + 'baz-vector.css', + 'quux-vector.less', + ), + ), + 'messages' => array( + 'hello', + 'world', + ), + ); + + $module = new ResourceLoaderFileModule( $baseParams ); + + $this->assertEquals( + array( + 'foo.css', + 'baz-vector.css', + 'quux-vector.less', + 'quux-fallback.less', + 'bar.css', + 'screen.less', + 'screen-query.css', + ), + array_map( 'basename', $module->getAllStyleFiles() ) + ); + } + + /** + * @covers ResourceLoaderModule::getDefinitionSummary + * @covers ResourceLoaderFileModule::getDefinitionSummary + */ + public function testDefinitionSummary() { + $context = self::getResourceLoaderContext(); + + $baseParams = array( + 'scripts' => array( 'foo.js', 'bar.js' ), + 'dependencies' => array( 'jquery', 'mediawiki' ), + 'messages' => array( 'hello', 'world' ), + ); + + $module = new ResourceLoaderFileModule( $baseParams ); + + $jsonSummary = json_encode( $module->getDefinitionSummary( $context ) ); + + // Exactly the same + $module = new ResourceLoaderFileModule( $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Instance is insignificant' + ); + + // Re-order dependencies + $module = new ResourceLoaderFileModule( array( + 'dependencies' => array( 'mediawiki', 'jquery' ), + ) + $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of dependencies is insignificant' + ); + + // Re-order messages + $module = new ResourceLoaderFileModule( array( + 'messages' => array( 'world', 'hello' ), + ) + $baseParams ); + + $this->assertEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of messages is insignificant' + ); + + // Re-order scripts + $module = new ResourceLoaderFileModule( array( + 'scripts' => array( 'bar.js', 'foo.js' ), + ) + $baseParams ); + + $this->assertNotEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Order of scripts is significant' + ); + + // Subclass + $module = new ResourceLoaderFileModuleTestModule( $baseParams ); + + $this->assertNotEquals( + $jsonSummary, + json_encode( $module->getDefinitionSummary( $context ) ), + 'Class is significant' + ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php new file mode 100644 index 00000000..a1893873 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderStartupModuleTest.php @@ -0,0 +1,388 @@ +<?php + +class ResourceLoaderStartupModuleTest extends ResourceLoaderTestCase { + + public static function provideGetModuleRegistrations() { + return array( + array( array( + 'msg' => 'Empty registry', + 'modules' => array(), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [] );' + ) ), + array( array( + 'msg' => 'Basic registry', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ] +] );', + ) ), + array( array( + 'msg' => 'Group signature', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.group.foo' => new ResourceLoaderTestModule( array( 'group' => 'x-foo' ) ), + 'test.group.bar' => new ResourceLoaderTestModule( array( 'group' => 'x-bar' ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.group.foo", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.bar", + "1388534400", + [], + "x-bar" + ] +] );' + ) ), + array( array( + 'msg' => 'Different target (non-test should not be registered)', + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.target.foo' => new ResourceLoaderTestModule( array( 'targets' => array( 'x-foo' ) ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ] +] );' + ) ), + array( array( + 'msg' => 'Foreign source', + 'sources' => array( + 'example' => array( + 'loadScript' => 'http://example.org/w/load.php', + 'apiScript' => 'http://example.org/w/api.php', + ), + ), + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule( array( 'source' => 'example' ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php", + "example": "http://example.org/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400", + [], + null, + "example" + ] +] );' + ) ), + array( array( + 'msg' => 'Conditional dependency function', + 'modules' => array( + 'test.x.core' => new ResourceLoaderTestModule(), + 'test.x.polyfill' => new ResourceLoaderTestModule( array( + 'skipFunction' => 'return true;' + ) ), + 'test.y.polyfill' => new ResourceLoaderTestModule( array( + 'skipFunction' => + 'return !!(' . + ' window.JSON &&' . + ' JSON.parse &&' . + ' JSON.stringify' . + ');' + ) ), + 'test.z.foo' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + 'test.x.polyfil', + 'test.y.polyfil', + ), + ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.x.core", + "1388534400" + ], + [ + "test.x.polyfill", + "1388534400", + [], + null, + "local", + "return true;" + ], + [ + "test.y.polyfill", + "1388534400", + [], + null, + "local", + "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);" + ], + [ + "test.z.foo", + "1388534400", + [ + "test.x.core", + "test.x.polyfil", + "test.y.polyfil" + ] + ] +] );', + ) ), + array( array( + // This may seem like an edge case, but a plain MediaWiki core install + // with a few extensions installed is likely far more complex than this + // even, not to mention an install like Wikipedia. + // TODO: Make this even more realistic. + 'msg' => 'Advanced (everything combined)', + 'sources' => array( + 'example' => array( + 'loadScript' => 'http://example.org/w/load.php', + 'apiScript' => 'http://example.org/w/api.php', + ), + ), + 'modules' => array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.x.core' => new ResourceLoaderTestModule(), + 'test.x.util' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + ), + ) ), + 'test.x.foo' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + ), + ) ), + 'test.x.bar' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.core', + 'test.x.util', + ), + ) ), + 'test.x.quux' => new ResourceLoaderTestModule( array( + 'dependencies' => array( + 'test.x.foo', + 'test.x.bar', + 'test.x.util', + 'test.x.unknown', + ), + ) ), + 'test.group.foo.1' => new ResourceLoaderTestModule( array( + 'group' => 'x-foo', + ) ), + 'test.group.foo.2' => new ResourceLoaderTestModule( array( + 'group' => 'x-foo', + ) ), + 'test.group.bar.1' => new ResourceLoaderTestModule( array( + 'group' => 'x-bar', + ) ), + 'test.group.bar.2' => new ResourceLoaderTestModule( array( + 'group' => 'x-bar', + 'source' => 'example', + ) ), + 'test.target.foo' => new ResourceLoaderTestModule( array( + 'targets' => array( 'x-foo' ), + ) ), + 'test.target.bar' => new ResourceLoaderTestModule( array( + 'source' => 'example', + 'targets' => array( 'x-foo' ), + ) ), + ), + 'out' => ' +mw.loader.addSource( { + "local": "/w/load.php", + "example": "http://example.org/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.x.core", + "1388534400" + ], + [ + "test.x.util", + "1388534400", + [ + "test.x.core" + ] + ], + [ + "test.x.foo", + "1388534400", + [ + "test.x.core" + ] + ], + [ + "test.x.bar", + "1388534400", + [ + "test.x.util" + ] + ], + [ + "test.x.quux", + "1388534400", + [ + "test.x.foo", + "test.x.bar", + "test.x.unknown" + ] + ], + [ + "test.group.foo.1", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.foo.2", + "1388534400", + [], + "x-foo" + ], + [ + "test.group.bar.1", + "1388534400", + [], + "x-bar" + ], + [ + "test.group.bar.2", + "1388534400", + [], + "x-bar", + "example" + ] +] );' + ) ), + ); + } + + /** + * @dataProvider provideGetModuleRegistrations + * @covers ResourceLoaderStartupModule::optimizeDependencies + * @covers ResourceLoaderStartUpModule::getModuleRegistrations + * @covers ResourceLoader::makeLoaderSourcesScript + * @covers ResourceLoader::makeLoaderRegisterScript + */ + public function testGetModuleRegistrations( $case ) { + if ( isset( $case['sources'] ) ) { + $this->setMwGlobals( 'wgResourceLoaderSources', $case['sources'] ); + } + + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + + $rl->register( $case['modules'] ); + + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( + ltrim( $case['out'], "\n" ), + $module->getModuleRegistrations( $context ), + $case['msg'] + ); + } + + public static function provideRegistrations() { + return array( + array( array( + 'test.blank' => new ResourceLoaderTestModule(), + 'test.min' => new ResourceLoaderTestModule( array( + 'skipFunction' => + 'return !!(' . + ' window.JSON &&' . + ' JSON.parse &&' . + ' JSON.stringify' . + ');', + 'dependencies' => array( + 'test.blank', + ), + ) ), + ) ) + ); + } + /** + * @dataProvider provideRegistrations + */ + public function testRegistrationsMinified( $modules ) { + $this->setMwGlobals( 'wgResourceLoaderDebug', false ); + + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + $rl->register( $modules ); + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( +'mw.loader.addSource({"local":"/w/load.php"});' +. 'mw.loader.register([' +. '["test.blank","1388534400"],' +. '["test.min","1388534400",["test.blank"],null,"local",' +. '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"' +. ']]);', + $module->getModuleRegistrations( $context ), + 'Minified output' + ); + } + + /** + * @dataProvider provideRegistrations + */ + public function testRegistrationsUnminified( $modules ) { + $context = self::getResourceLoaderContext(); + $rl = $context->getResourceLoader(); + $rl->register( $modules ); + $module = new ResourceLoaderStartUpModule(); + $this->assertEquals( +'mw.loader.addSource( { + "local": "/w/load.php" +} );mw.loader.register( [ + [ + "test.blank", + "1388534400" + ], + [ + "test.min", + "1388534400", + [ + "test.blank" + ], + null, + "local", + "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);" + ] +] );', + $module->getModuleRegistrations( $context ), + 'Unminified output' + ); + } + +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php new file mode 100644 index 00000000..f19f6886 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php @@ -0,0 +1,249 @@ +<?php + +class ResourceLoaderTest extends ResourceLoaderTestCase { + + protected static $resourceLoaderRegisterModulesHook; + + protected function setUp() { + parent::setUp(); + + // $wgResourceLoaderLESSFunctions, $wgResourceLoaderLESSImportPaths; $wgResourceLoaderLESSVars; + + $this->setMwGlobals( array( + 'wgResourceLoaderLESSFunctions' => array( + 'test-sum' => function ( $frame, $less ) { + $sum = 0; + foreach ( $frame[2] as $arg ) { + $sum += (int)$arg[1]; + } + return $sum; + }, + ), + 'wgResourceLoaderLESSImportPaths' => array( + dirname( dirname( __DIR__ ) ) . '/data/less/common', + ), + 'wgResourceLoaderLESSVars' => array( + 'foo' => '2px', + 'Foo' => '#eeeeee', + 'bar' => 5, + ), + ) ); + } + + /* Hook Methods */ + + /** + * ResourceLoaderRegisterModules hook + */ + public static function resourceLoaderRegisterModules( &$resourceLoader ) { + self::$resourceLoaderRegisterModulesHook = true; + + return true; + } + + /* Provider Methods */ + public static function provideValidModules() { + return array( + array( 'TEST.validModule1', new ResourceLoaderTestModule() ), + ); + } + + /* Test Methods */ + + /** + * Ensures that the ResourceLoaderRegisterModules hook is called when a new + * ResourceLoader object is constructed. + * @covers ResourceLoader::__construct + */ + public function testCreatingNewResourceLoaderCallsRegistrationHook() { + self::$resourceLoaderRegisterModulesHook = false; + $resourceLoader = new ResourceLoader(); + $this->assertTrue( self::$resourceLoaderRegisterModulesHook ); + + return $resourceLoader; + } + + /** + * @dataProvider provideValidModules + * @depends testCreatingNewResourceLoaderCallsRegistrationHook + * @covers ResourceLoader::register + * @covers ResourceLoader::getModule + */ + public function testRegisteredValidModulesAreAccessible( + $name, ResourceLoaderModule $module, ResourceLoader $resourceLoader + ) { + $resourceLoader->register( $name, $module ); + $this->assertEquals( $module, $resourceLoader->getModule( $name ) ); + } + + /** + * @covers ResourceLoaderFileModule::compileLessFile + */ + public function testLessFileCompilation() { + $context = self::getResourceLoaderContext(); + $basePath = __DIR__ . '/../../data/less/module'; + $module = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'styles.less' ), + ) ); + $module->setName( 'test.less' ); + $styles = $module->getStyles( $context ); + $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] ); + } + + /** + * Strip @noflip annotations from CSS code. + * @param string $css + * @return string + */ + private function stripNoflip( $css ) { + return str_replace( '/*@noflip*/ ', '', $css ); + } + + /** + * What happens when you mix @embed and @noflip? + * This really is an integration test, but oh well. + */ + public function testMixedCssAnnotations( ) { + $basePath = __DIR__ . '/../../data/css'; + $testModule = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'test.css' ), + ) ); + $expectedModule = new ResourceLoaderFileModule( array( + 'localBasePath' => $basePath, + 'styles' => array( 'expected.css' ), + ) ); + + $contextLtr = self::getResourceLoaderContext( 'en' ); + $contextRtl = self::getResourceLoaderContext( 'he' ); + + // Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and + // the @noflip annotations are always preserved, we need to strip them first. + $this->assertEquals( + $expectedModule->getStyles( $contextLtr ), + $this->stripNoflip( $testModule->getStyles( $contextLtr ) ), + "/*@noflip*/ with /*@embed*/ gives correct results in LTR mode" + ); + $this->assertEquals( + $expectedModule->getStyles( $contextLtr ), + $this->stripNoflip( $testModule->getStyles( $contextRtl ) ), + "/*@noflip*/ with /*@embed*/ gives correct results in RTL mode" + ); + } + + /** + * @dataProvider providePackedModules + * @covers ResourceLoader::makePackedModulesString + */ + public function testMakePackedModulesString( $desc, $modules, $packed ) { + $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc ); + } + + /** + * @dataProvider providePackedModules + * @covers ResourceLoaderContext::expandModuleNames + */ + public function testexpandModuleNames( $desc, $modules, $packed ) { + $this->assertEquals( $modules, ResourceLoaderContext::expandModuleNames( $packed ), $desc ); + } + + public static function providePackedModules() { + return array( + array( + 'Example from makePackedModulesString doc comment', + array( 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ), + 'foo.bar,baz|bar.baz,quux', + ), + array( + 'Example from expandModuleNames doc comment', + array( 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ), + 'jquery.foo,bar|jquery.ui.baz,quux', + ), + array( + 'Regression fixed in r88706 with dotless names', + array( 'foo', 'bar', 'baz' ), + 'foo,bar,baz', + ), + array( + 'Prefixless modules after a prefixed module', + array( 'single.module', 'foobar', 'foobaz' ), + 'single.module|foobar,foobaz', + ), + ); + } + + public static function provideAddSource() { + return array( + array( 'examplewiki', '//example.org/w/load.php', 'examplewiki' ), + array( 'example2wiki', array( 'loadScript' => '//example.com/w/load.php' ), 'example2wiki' ), + array( + array( 'foowiki' => '//foo.org/w/load.php', 'bazwiki' => '//baz.org/w/load.php' ), + null, + array( 'foowiki', 'bazwiki' ) + ), + array( + array( 'foowiki' => '//foo.org/w/load.php' ), + null, + false, + ), + ); + } + + /** + * @dataProvider provideAddSource + * @covers ResourceLoader::addSource + */ + public function testAddSource( $name, $info, $expected ) { + $rl = new ResourceLoader; + if ( $expected === false ) { + $this->setExpectedException( 'MWException', 'ResourceLoader duplicate source addition error' ); + $rl->addSource( $name, $info ); + } + $rl->addSource( $name, $info ); + if ( is_array( $expected ) ) { + foreach ( $expected as $source ) { + $this->assertArrayHasKey( $source, $rl->getSources() ); + } + } else { + $this->assertArrayHasKey( $expected, $rl->getSources() ); + } + } + + public static function fakeSources() { + return array( + 'examplewiki' => array( + 'loadScript' => '//example.org/w/load.php', + 'apiScript' => '//example.org/w/api.php', + ), + 'example2wiki' => array( + 'loadScript' => '//example.com/w/load.php', + 'apiScript' => '//example.com/w/api.php', + ), + ); + } + + /** + * @covers ResourceLoader::getLoadScript + */ + public function testGetLoadScript() { + $this->setMwGlobals( 'wgResourceLoaderSources', array() ); + $rl = new ResourceLoader(); + $sources = self::fakeSources(); + $rl->addSource( $sources ); + foreach ( array( 'examplewiki', 'example2wiki' ) as $name ) { + $this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] ); + } + + try { + $rl->getLoadScript( 'thiswasneverreigstered' ); + $this->assertTrue( false, 'ResourceLoader::getLoadScript should have thrown an exception' ); + } catch ( MWException $e ) { + $this->assertTrue( true ); + } + } +} + +/* Hooks */ +global $wgHooks; +$wgHooks['ResourceLoaderRegisterModules'][] = 'ResourceLoaderTest::resourceLoaderRegisterModules'; diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php new file mode 100644 index 00000000..9dc18050 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php @@ -0,0 +1,67 @@ +<?php + +class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase { + + /** + * @covers ResourceLoaderWikiModule::isKnownEmpty + * @dataProvider provideIsKnownEmpty + */ + public function testIsKnownEmpty( $titleInfo, $group, $expected ) { + $module = $this->getMockBuilder( 'ResourceLoaderWikiModuleTestModule' ) + ->setMethods( array( 'getTitleInfo', 'getGroup' ) ) + ->getMock(); + $module->expects( $this->any() ) + ->method( 'getTitleInfo' ) + ->will( $this->returnValue( $titleInfo ) ); + $module->expects( $this->any() ) + ->method( 'getGroup' ) + ->will( $this->returnValue( $group ) ); + $context = $this->getMockBuilder( 'ResourceLoaderContext' ) + ->disableOriginalConstructor() + ->getMock(); + $this->assertEquals( $expected, $module->isKnownEmpty( $context ) ); + } + + public static function provideIsKnownEmpty() { + return array( + // No valid pages + array( array(), 'test1', true ), + // 'site' module with a non-empty page + array( + array( + 'MediaWiki:Common.js' => array( + 'timestamp' => 123456789, + 'length' => 1234 + ) + ), 'site', false, + ), + // 'site' module with an empty page + array( + array( + 'MediaWiki:Monobook.js' => array( + 'timestamp' => 987654321, + 'length' => 0, + ), + ), 'site', false, + ), + // 'user' module with a non-empty page + array( + array( + 'User:FooBar/common.js' => array( + 'timestamp' => 246813579, + 'length' => 25, + ), + ), 'user', false, + ), + // 'user' module with an empty page + array( + array( + 'User:FooBar/monobook.js' => array( + 'timestamp' => 1357924680, + 'length' => 0, + ), + ), 'user', true, + ), + ); + } +} diff --git a/tests/phpunit/includes/search/SearchEngineTest.php b/tests/phpunit/includes/search/SearchEngineTest.php new file mode 100644 index 00000000..3da13615 --- /dev/null +++ b/tests/phpunit/includes/search/SearchEngineTest.php @@ -0,0 +1,187 @@ +<?php + +/** + * @group Search + * @group Database + * + * @covers SearchEngine<extended> + * @note Coverage will only ever show one of on of the Search* classes + */ +class SearchEngineTest extends MediaWikiLangTestCase { + + /** + * @var SearchEngine + */ + protected $search; + + protected $pageList; + + /** + * Checks for database type & version. + * Will skip current test if DB does not support search. + */ + protected function setUp() { + parent::setUp(); + + // Search tests require MySQL or SQLite with FTS + $dbType = $this->db->getType(); + $dbSupported = ( $dbType === 'mysql' ) + || ( $dbType === 'sqlite' && $this->db->getFulltextSearchModule() == 'FTS3' ); + + if ( !$dbSupported ) { + $this->markTestSkipped( "MySQL or SQLite with FTS3 only" ); + } + + $searchType = $this->db->getSearchEngine(); + $this->setMwGlobals( array( + 'wgSearchType' => $searchType + ) ); + + if ( !isset( self::$pageList ) ) { + $this->addPages(); + } + + $this->search = new $searchType( $this->db ); + } + + protected function tearDown() { + unset( $this->search ); + + parent::tearDown(); + } + + protected function addPages() { + if ( !$this->isWikitextNS( NS_MAIN ) ) { + // @todo cover the case of non-wikitext content in the main namespace + return; + } + + $this->insertPage( "Not_Main_Page", "This is not a main page", 0 ); + $this->insertPage( + 'Talk:Not_Main_Page', + 'This is not a talk page to the main page, see [[smithee]]', + 1 + ); + $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]', 0 ); + $this->insertPage( 'Talk:Smithee', 'This article sucks.', 1 ); + $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.', 0 ); + $this->insertPage( 'Another_page', 'This page also is unrelated.', 0 ); + $this->insertPage( 'Help:Help', 'Help me!', 4 ); + $this->insertPage( 'Thppt', 'Blah blah', 0 ); + $this->insertPage( 'Alan_Smithee', 'yum', 0 ); + $this->insertPage( 'Pages', 'are\'food', 0 ); + $this->insertPage( 'HalfOneUp', 'AZ', 0 ); + $this->insertPage( 'FullOneUp', 'AZ', 0 ); + $this->insertPage( 'HalfTwoLow', 'az', 0 ); + $this->insertPage( 'FullTwoLow', 'az', 0 ); + $this->insertPage( 'HalfNumbers', '1234567890', 0 ); + $this->insertPage( 'FullNumbers', '1234567890', 0 ); + $this->insertPage( 'DomainName', 'example.com', 0 ); + } + + protected function fetchIds( $results ) { + if ( !$this->isWikitextNS( NS_MAIN ) ) { + $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content " + . "in the main namespace" ); + } + $this->assertTrue( is_object( $results ) ); + + $matches = array(); + $row = $results->next(); + while ( $row ) { + $matches[] = $row->getTitle()->getPrefixedText(); + $row = $results->next(); + } + $results->free(); + # Search is not guaranteed to return results in a certain order; + # sort them numerically so we will compare simply that we received + # the expected matches. + sort( $matches ); + + return $matches; + } + + /** + * Insert a new page + * + * @param string $pageName Page name + * @param string $text Page's content + * @param int $ns Unused + */ + protected function insertPage( $pageName, $text, $ns ) { + $title = Title::newFromText( $pageName, $ns ); + + $user = User::newFromName( 'WikiSysop' ); + $comment = 'Search Test'; + + // avoid memory leak...? + LinkCache::singleton()->clear(); + + $page = WikiPage::factory( $title ); + $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user ); + + $this->pageList[] = array( $title, $page->getId() ); + + return true; + } + + public function testFullWidth() { + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'AZ' ) ), + "Search for normalized from Half-width Upper" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'az' ) ), + "Search for normalized from Half-width Lower" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'AZ' ) ), + "Search for normalized from Full-width Upper" ); + $this->assertEquals( + array( 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ), + $this->fetchIds( $this->search->searchText( 'az' ) ), + "Search for normalized from Full-width Lower" ); + } + + public function testTextSearch() { + $this->assertEquals( + array( 'Smithee' ), + $this->fetchIds( $this->search->searchText( 'smithee' ) ), + "Plain search failed" ); + } + + public function testTextPowerSearch() { + $this->search->setNamespaces( array( 0, 1, 4 ) ); + $this->assertEquals( + array( + 'Smithee', + 'Talk:Not Main Page', + ), + $this->fetchIds( $this->search->searchText( 'smithee' ) ), + "Power search failed" ); + } + + public function testTitleSearch() { + $this->assertEquals( + array( + 'Alan Smithee', + 'Smithee', + ), + $this->fetchIds( $this->search->searchTitle( 'smithee' ) ), + "Title search failed" ); + } + + public function testTextTitlePowerSearch() { + $this->search->setNamespaces( array( 0, 1, 4 ) ); + $this->assertEquals( + array( + 'Alan Smithee', + 'Smithee', + 'Talk:Smithee', + ), + $this->fetchIds( $this->search->searchTitle( 'smithee' ) ), + "Title power search failed" ); + } + +} diff --git a/tests/phpunit/includes/search/SearchUpdateTest.php b/tests/phpunit/includes/search/SearchUpdateTest.php new file mode 100644 index 00000000..c6275371 --- /dev/null +++ b/tests/phpunit/includes/search/SearchUpdateTest.php @@ -0,0 +1,81 @@ +<?php + +class MockSearch extends SearchEngine { + public static $id; + public static $title; + public static $text; + + public function __construct( $db ) { + } + + public function update( $id, $title, $text ) { + self::$id = $id; + self::$title = $title; + self::$text = $text; + } +} + +/** + * @group Search + * @group Database + */ +class SearchUpdateTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + $this->setMwGlobals( 'wgSearchType', 'MockSearch' ); + } + + public function updateText( $text ) { + return trim( SearchUpdate::updateText( $text ) ); + } + + /** + * @covers SearchUpdate::updateText + */ + public function testUpdateText() { + $this->assertEquals( + 'test', + $this->updateText( '<div>TeSt</div>' ), + 'HTML stripped, text lowercased' + ); + + $this->assertEquals( + 'foo bar boz quux', + $this->updateText( <<<EOT +<table style="color:red; font-size:100px"> + <tr class="scary"><td><div>foo</div></td><tr>bar</td></tr> + <tr><td>boz</td><tr>quux</td></tr> +</table> +EOT + ), 'Stripping HTML tables' ); + + $this->assertEquals( + 'a b', + $this->updateText( 'a > b' ), + 'Handle unclosed tags' + ); + + $text = str_pad( "foo <barbarbar \n", 10000, 'x' ); + + $this->assertNotEquals( + '', + $this->updateText( $text ), + 'Bug 18609' + ); + } + + /** + * @covers SearchUpdate::updateText + * @todo give this test a real name explaining what is being tested here + */ + public function testBug32712() { + $text = "text „http://example.com“ text"; + $result = $this->updateText( $text ); + $processed = preg_replace( '/Q/u', 'Q', $result ); + $this->assertTrue( + $processed != '', + 'Link surrounded by unicode quotes should not fail UTF-8 validation' + ); + } +} diff --git a/tests/phpunit/includes/site/MediaWikiSiteTest.php b/tests/phpunit/includes/site/MediaWikiSiteTest.php new file mode 100644 index 00000000..c3fd1557 --- /dev/null +++ b/tests/phpunit/includes/site/MediaWikiSiteTest.php @@ -0,0 +1,109 @@ +<?php + +/** + * Tests for the MediaWikiSite class. + * + * 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 + * @since 1.21 + * + * @ingroup Site + * @ingroup Test + * + * @group Site + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class MediaWikiSiteTest extends SiteTest { + + public function testNormalizePageTitle() { + $this->setMwGlobals( array( + 'wgCapitalLinks' => true, + ) ); + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiki' ); + + //NOTE: this does not actually call out to the enwiki site to perform the normalization, + // but uses a local Title object to do so. This is hardcoded on SiteLink::normalizePageTitle + // for the case that MW_PHPUNIT_TEST is set. + $this->assertEquals( 'Foo', $site->normalizePageName( ' foo ' ) ); + } + + public function fileUrlProvider() { + return array( + // url, filepath, path arg, expected + array( 'https://en.wikipedia.org', '/w/$1', 'api.php', 'https://en.wikipedia.org/w/api.php' ), + array( 'https://en.wikipedia.org', '/w/', 'api.php', 'https://en.wikipedia.org/w/' ), + array( + 'https://en.wikipedia.org', + '/foo/page.php?name=$1', + 'api.php', + 'https://en.wikipedia.org/foo/page.php?name=api.php' + ), + array( + 'https://en.wikipedia.org', + '/w/$1', + '', + 'https://en.wikipedia.org/w/' + ), + array( + 'https://en.wikipedia.org', + '/w/$1', + 'foo/bar/api.php', + 'https://en.wikipedia.org/w/foo/bar/api.php' + ), + ); + } + + /** + * @dataProvider fileUrlProvider + * @covers MediaWikiSite::getFileUrl + */ + public function testGetFileUrl( $url, $filePath, $pathArgument, $expected ) { + $site = new MediaWikiSite(); + $site->setFilePath( $url . $filePath ); + + $this->assertEquals( $expected, $site->getFileUrl( $pathArgument ) ); + } + + public static function provideGetPageUrl() { + return array( + // path, page, expected substring + array( 'http://acme.test/wiki/$1', 'Berlin', '/wiki/Berlin' ), + array( 'http://acme.test/wiki/', 'Berlin', '/wiki/' ), + array( 'http://acme.test/w/index.php?title=$1', 'Berlin', '/w/index.php?title=Berlin' ), + array( 'http://acme.test/wiki/$1', '', '/wiki/' ), + array( 'http://acme.test/wiki/$1', 'Berlin/sub page', '/wiki/Berlin/sub_page' ), + array( 'http://acme.test/wiki/$1', 'Cork (city) ', '/Cork_(city)' ), + array( 'http://acme.test/wiki/$1', 'M&M', '/wiki/M%26M' ), + ); + } + + /** + * @dataProvider provideGetPageUrl + * @covers MediaWikiSite::getPageUrl + */ + public function testGetPageUrl( $path, $page, $expected ) { + $site = new MediaWikiSite(); + $site->setLinkPath( $path ); + + $this->assertContains( $path, $site->getPageUrl() ); + $this->assertContains( $expected, $site->getPageUrl( $page ) ); + } +} diff --git a/tests/phpunit/includes/site/SiteListTest.php b/tests/phpunit/includes/site/SiteListTest.php new file mode 100644 index 00000000..534ed9c9 --- /dev/null +++ b/tests/phpunit/includes/site/SiteListTest.php @@ -0,0 +1,240 @@ +<?php + +/** + * Tests for the SiteList class. + * + * 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 + * @since 1.21 + * + * @ingroup Site + * @ingroup Test + * + * @group Site + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class SiteListTest extends MediaWikiTestCase { + + /** + * Returns instances of SiteList implementing objects. + * @return array + */ + public function siteListProvider() { + $sitesArrays = $this->siteArrayProvider(); + + $listInstances = array(); + + foreach ( $sitesArrays as $sitesArray ) { + $listInstances[] = new SiteList( $sitesArray[0] ); + } + + return $this->arrayWrap( $listInstances ); + } + + /** + * Returns arrays with instances of Site implementing objects. + * @return array + */ + public function siteArrayProvider() { + $sites = TestSites::getSites(); + + $siteArrays = array(); + + $siteArrays[] = $sites; + + $siteArrays[] = array( array_shift( $sites ) ); + + $siteArrays[] = array( array_shift( $sites ), array_shift( $sites ) ); + + return $this->arrayWrap( $siteArrays ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::isEmpty + */ + public function testIsEmpty( SiteList $sites ) { + $this->assertEquals( count( $sites ) === 0, $sites->isEmpty() ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSite + */ + public function testGetSiteByGlobalId( SiteList $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertEquals( $site, $sites->getSite( $site->getGlobalId() ) ); + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSiteByInternalId + */ + public function testGetSiteByInternalId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + if ( is_integer( $site->getInternalId() ) ) { + $this->assertEquals( $site, $sites->getSiteByInternalId( $site->getInternalId() ) ); + } + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getSiteByNavigationId + */ + public function testGetSiteByNavigationId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $ids = $site->getNavigationIds(); + foreach ( $ids as $navId ) { + $this->assertEquals( $site, $sites->getSiteByNavigationId( $navId ) ); + } + } + + $this->assertTrue( true ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasSite + */ + public function testHasGlobalId( $sites ) { + $this->assertFalse( $sites->hasSite( 'non-existing-global-id' ) ); + $this->assertFalse( $sites->hasInternalId( 720101010 ) ); + + if ( !$sites->isEmpty() ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasInternalId + */ + public function testHasInternallId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + if ( is_integer( $site->getInternalId() ) ) { + $this->assertTrue( $site, $sites->hasInternalId( $site->getInternalId() ) ); + } + } + + $this->assertFalse( $sites->hasInternalId( -1 ) ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::hasNavigationId + */ + public function testHasNavigationId( $sites ) { + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $ids = $site->getNavigationIds(); + foreach ( $ids as $navId ) { + $this->assertTrue( $sites->hasNavigationId( $navId ) ); + } + } + + $this->assertFalse( $sites->hasNavigationId( 'non-existing-navigation-id' ) ); + } + + /** + * @dataProvider siteListProvider + * @param SiteList $sites + * @covers SiteList::getGlobalIdentifiers + */ + public function testGetGlobalIdentifiers( SiteList $sites ) { + $identifiers = $sites->getGlobalIdentifiers(); + + $this->assertTrue( is_array( $identifiers ) ); + + $expected = array(); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $expected[] = $site->getGlobalId(); + } + + $this->assertArrayEquals( $expected, $identifiers ); + } + + /** + * @dataProvider siteListProvider + * + * @since 1.21 + * + * @param SiteList $list + * @covers SiteList::getSerializationData + * @covers SiteList::unserialize + */ + public function testSerialization( SiteList $list ) { + $serialization = serialize( $list ); + /** + * @var SiteArray $copy + */ + $copy = unserialize( $serialization ); + + $this->assertArrayEquals( $list->getGlobalIdentifiers(), $copy->getGlobalIdentifiers() ); + + /** + * @var Site $site + */ + foreach ( $list as $site ) { + $this->assertTrue( $copy->hasInternalId( $site->getInternalId() ) ); + + foreach ( $site->getNavigationIds() as $navId ) { + $this->assertTrue( + $copy->hasNavigationId( $navId ), + 'unserialized data expects nav id ' . $navId . ' for site ' . $site->getGlobalId() + ); + } + } + } +} diff --git a/tests/phpunit/includes/site/SiteSQLStoreTest.php b/tests/phpunit/includes/site/SiteSQLStoreTest.php new file mode 100644 index 00000000..6002c1a1 --- /dev/null +++ b/tests/phpunit/includes/site/SiteSQLStoreTest.php @@ -0,0 +1,134 @@ +<?php + +/** + * Tests for the SiteSQLStore class. + * + * 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 + * @since 1.21 + * + * @ingroup Site + * @ingroup Test + * + * @group Site + * @group Database + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class SiteSQLStoreTest extends MediaWikiTestCase { + + /** + * @covers SiteSQLStore::getSites + */ + public function testGetSites() { + $expectedSites = TestSites::getSites(); + TestSites::insertIntoDb(); + + $store = SiteSQLStore::newInstance(); + + $sites = $store->getSites(); + + $this->assertInstanceOf( 'SiteList', $sites ); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertInstanceOf( 'Site', $site ); + } + + foreach ( $expectedSites as $site ) { + if ( $site->getGlobalId() !== null ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @covers SiteSQLStore::saveSites + */ + public function testSaveSites() { + $store = SiteSQLStore::newInstance(); + + $sites = array(); + + $site = new Site(); + $site->setGlobalId( 'ertrywuutr' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'sdfhxujgkfpth' ); + $site->setLanguageCode( 'nl' ); + $sites[] = $site; + + $this->assertTrue( $store->saveSites( $sites ) ); + + $site = $store->getSite( 'ertrywuutr' ); + $this->assertInstanceOf( 'Site', $site ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + $this->assertTrue( is_integer( $site->getInternalId() ) ); + $this->assertTrue( $site->getInternalId() >= 0 ); + + $site = $store->getSite( 'sdfhxujgkfpth' ); + $this->assertInstanceOf( 'Site', $site ); + $this->assertEquals( 'nl', $site->getLanguageCode() ); + $this->assertTrue( is_integer( $site->getInternalId() ) ); + $this->assertTrue( $site->getInternalId() >= 0 ); + } + + /** + * @covers SiteSQLStore::reset + */ + public function testReset() { + $store1 = SiteSQLStore::newInstance(); + $store2 = SiteSQLStore::newInstance(); + + // initialize internal cache + $this->assertGreaterThan( 0, $store1->getSites()->count() ); + $this->assertGreaterThan( 0, $store2->getSites()->count() ); + + // Clear actual data. Will purge the external cache and reset the internal + // cache in $store1, but not the internal cache in store2. + $this->assertTrue( $store1->clear() ); + + // sanity check: $store2 should have a stale cache now + $this->assertNotNull( $store2->getSite( 'enwiki' ) ); + + // purge cache + $store2->reset(); + + // ...now the internal cache of $store2 should be updated and thus empty. + $site = $store2->getSite( 'enwiki' ); + $this->assertNull( $site ); + } + + /** + * @covers SiteSQLStore::clear + */ + public function testClear() { + $store = SiteSQLStore::newInstance(); + $this->assertTrue( $store->clear() ); + + $site = $store->getSite( 'enwiki' ); + $this->assertNull( $site ); + + $sites = $store->getSites(); + $this->assertEquals( 0, $sites->count() ); + } +} diff --git a/tests/phpunit/includes/site/SiteTest.php b/tests/phpunit/includes/site/SiteTest.php new file mode 100644 index 00000000..29c1ff33 --- /dev/null +++ b/tests/phpunit/includes/site/SiteTest.php @@ -0,0 +1,296 @@ +<?php + +/** + * Tests for the Site class. + * + * 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 + * @since 1.21 + * + * @ingroup Site + * @ingroup Test + * + * @group Site + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class SiteTest extends MediaWikiTestCase { + + public function instanceProvider() { + return $this->arrayWrap( TestSites::getSites() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getInterwikiIds + */ + public function testGetInterwikiIds( Site $site ) { + $this->assertInternalType( 'array', $site->getInterwikiIds() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getNavigationIds + */ + public function testGetNavigationIds( Site $site ) { + $this->assertInternalType( 'array', $site->getNavigationIds() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::addNavigationId + */ + public function testAddNavigationId( Site $site ) { + $site->addNavigationId( 'foobar' ); + $this->assertTrue( in_array( 'foobar', $site->getNavigationIds(), true ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::addInterwikiId + */ + public function testAddInterwikiId( Site $site ) { + $site->addInterwikiId( 'foobar' ); + $this->assertTrue( in_array( 'foobar', $site->getInterwikiIds(), true ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getLanguageCode + */ + public function testGetLanguageCode( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getLanguageCode(), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setLanguageCode + */ + public function testSetLanguageCode( Site $site ) { + $site->setLanguageCode( 'en' ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::normalizePageName + */ + public function testNormalizePageName( Site $site ) { + $this->assertInternalType( 'string', $site->normalizePageName( 'Foobar' ) ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getGlobalId + */ + public function testGetGlobalId( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getGlobalId(), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setGlobalId + */ + public function testSetGlobalId( Site $site ) { + $site->setGlobalId( 'foobar' ); + $this->assertEquals( 'foobar', $site->getGlobalId() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getType + */ + public function testGetType( Site $site ) { + $this->assertInternalType( 'string', $site->getType() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getPath + */ + public function testGetPath( Site $site ) { + $this->assertTypeOrValue( 'string', $site->getPath( 'page_path' ), null ); + $this->assertTypeOrValue( 'string', $site->getPath( 'file_path' ), null ); + $this->assertTypeOrValue( 'string', $site->getPath( 'foobar' ), null ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::getAllPaths + */ + public function testGetAllPaths( Site $site ) { + $this->assertInternalType( 'array', $site->getAllPaths() ); + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::setPath + * @covers Site::removePath + */ + public function testSetAndRemovePath( Site $site ) { + $count = count( $site->getAllPaths() ); + + $site->setPath( 'spam', 'http://www.wikidata.org/$1' ); + $site->setPath( 'spam', 'http://www.wikidata.org/foo/$1' ); + $site->setPath( 'foobar', 'http://www.wikidata.org/bar/$1' ); + + $this->assertEquals( $count + 2, count( $site->getAllPaths() ) ); + + $this->assertInternalType( 'string', $site->getPath( 'foobar' ) ); + $this->assertEquals( 'http://www.wikidata.org/foo/$1', $site->getPath( 'spam' ) ); + + $site->removePath( 'spam' ); + $site->removePath( 'foobar' ); + + $this->assertEquals( $count, count( $site->getAllPaths() ) ); + + $this->assertNull( $site->getPath( 'foobar' ) ); + $this->assertNull( $site->getPath( 'spam' ) ); + } + + /** + * @covers Site::setLinkPath + */ + public function testSetLinkPath() { + $site = new Site(); + $path = "TestPath/$1"; + + $site->setLinkPath( $path ); + $this->assertEquals( $path, $site->getLinkPath() ); + } + + /** + * @covers Site::getLinkPathType + */ + public function testGetLinkPathType() { + $site = new Site(); + + $path = 'TestPath/$1'; + $site->setLinkPath( $path ); + $this->assertEquals( $path, $site->getPath( $site->getLinkPathType() ) ); + + $path = 'AnotherPath/$1'; + $site->setPath( $site->getLinkPathType(), $path ); + $this->assertEquals( $path, $site->getLinkPath() ); + } + + /** + * @covers Site::setPath + */ + public function testSetPath() { + $site = new Site(); + + $path = 'TestPath/$1'; + $site->setPath( 'foo', $path ); + + $this->assertEquals( $path, $site->getPath( 'foo' ) ); + } + + /** + * @covers Site::setPath + * @covers Site::getProtocol + */ + public function testProtocolRelativePath() { + $site = new Site(); + + $type = $site->getLinkPathType(); + $path = '//acme.com/'; // protocol-relative URL + $site->setPath( $type, $path ); + + $this->assertEquals( '', $site->getProtocol() ); + } + + public static function provideGetPageUrl() { + //NOTE: the assumption that the URL is built by replacing $1 + // with the urlencoded version of $page + // is true for Site but not guaranteed for subclasses. + // Subclasses need to override this provider appropriately. + + return array( + array( #0 + 'http://acme.test/TestPath/$1', + 'Foo', + '/TestPath/Foo', + ), + array( #1 + 'http://acme.test/TestScript?x=$1&y=bla', + 'Foo', + 'TestScript?x=Foo&y=bla', + ), + array( #2 + 'http://acme.test/TestPath/$1', + 'foo & bar/xyzzy (quux-shmoox?)', + '/TestPath/foo%20%26%20bar%2Fxyzzy%20%28quux-shmoox%3F%29', + ), + ); + } + + /** + * @dataProvider provideGetPageUrl + * @covers Site::getPageUrl + */ + public function testGetPageUrl( $path, $page, $expected ) { + $site = new Site(); + + //NOTE: the assumption that getPageUrl is based on getLinkPath + // is true for Site but not guaranteed for subclasses. + // Subclasses need to override this test case appropriately. + $site->setLinkPath( $path ); + $this->assertContains( $path, $site->getPageUrl() ); + + $this->assertContains( $expected, $site->getPageUrl( $page ) ); + } + + protected function assertTypeOrFalse( $type, $value ) { + if ( $value === false ) { + $this->assertTrue( true ); + } else { + $this->assertInternalType( $type, $value ); + } + } + + /** + * @dataProvider instanceProvider + * @param Site $site + * @covers Site::serialize + * @covers Site::unserialize + */ + public function testSerialization( Site $site ) { + $this->assertInstanceOf( 'Serializable', $site ); + + $serialization = serialize( $site ); + $newInstance = unserialize( $serialization ); + + $this->assertInstanceOf( 'Site', $newInstance ); + + $this->assertEquals( $serialization, serialize( $newInstance ) ); + } +} diff --git a/tests/phpunit/includes/site/TestSites.php b/tests/phpunit/includes/site/TestSites.php new file mode 100644 index 00000000..af314ba2 --- /dev/null +++ b/tests/phpunit/includes/site/TestSites.php @@ -0,0 +1,115 @@ +<?php + +/** + * Holds sites for testing purposes. + * + * 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 + * @since 1.21 + * + * @ingroup Site + * @ingroup Test + * + * @group Site + * + * @licence GNU GPL v2+ + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +class TestSites { + + /** + * @since 1.21 + * + * @return array + */ + public static function getSites() { + $sites = array(); + + $site = new Site(); + $site->setGlobalId( 'foobar' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'enwiktionary' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'dewiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'de' ); + $site->addInterwikiId( 'dewiktionary' ); + $site->addInterwikiId( 'wiktionaryde' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://de.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://de.wiktionary.org/w/$1" ); + $sites[] = $site; + + $site = new Site(); + $site->setGlobalId( 'spam' ); + $site->setGroup( 'spam' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'spam' ); + $site->addNavigationId( 'spamz' ); + $site->addInterwikiId( 'spamzz' ); + $site->setLinkPath( "http://spamzz.test/testing/" ); + $sites[] = $site; + + /** + * Add at least one right-to-left language (current RTL languages in MediaWiki core are: + * aeb, ar, arc, arz, azb, bcc, bqi, ckb, dv, en_rtl, fa, glk, he, khw, kk_arab, kk_cn, + * ks_arab, ku_arab, lrc, mzn, pnb, ps, sd, ug_arab, ur, yi). + */ + $languageCodes = array( + 'de', + 'en', + 'fa', //right-to-left + 'nl', + 'nn', + 'no', + 'sr', + 'sv', + ); + foreach ( $languageCodes as $langCode ) { + $site = new MediaWikiSite(); + $site->setGlobalId( $langCode . 'wiki' ); + $site->setGroup( 'wikipedia' ); + $site->setLanguageCode( $langCode ); + $site->addInterwikiId( $langCode ); + $site->addNavigationId( $langCode ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://$langCode.wikipedia.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://$langCode.wikipedia.org/w/$1" ); + $sites[] = $site; + } + + return $sites; + } + + /** + * Inserts sites into the database for the unit tests that need them. + * + * @since 0.1 + */ + public static function insertIntoDb() { + $sitesTable = SiteSQLStore::newInstance(); + $sitesTable->clear(); + $sitesTable->saveSites( TestSites::getSites() ); + } +} diff --git a/tests/phpunit/includes/skins/SkinFactoryTest.php b/tests/phpunit/includes/skins/SkinFactoryTest.php new file mode 100644 index 00000000..d3663c84 --- /dev/null +++ b/tests/phpunit/includes/skins/SkinFactoryTest.php @@ -0,0 +1,70 @@ +<?php + +class SkinFactoryTest extends MediaWikiTestCase { + + /** + * @covers SkinFactory::register + */ + public function testRegister() { + $factory = new SkinFactory(); + $factory->register( 'fallback', 'Fallback', function () { + return new SkinFallback(); + } ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( 'InvalidArgumentException' ); + $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithNoBuilders() { + $factory = new SkinFactory(); + $this->setExpectedException( 'SkinException' ); + $factory->makeSkin( 'nobuilderregistered' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithInvalidCallback() { + $factory = new SkinFactory(); + $factory->register( 'unittest', 'Unittest', function () { + return true; // Not a Skin object + } ); + $this->setExpectedException( 'UnexpectedValueException' ); + $factory->makeSkin( 'unittest' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithValidCallback() { + $factory = new SkinFactory(); + $factory->register( 'testfallback', 'TestFallback', function () { + return new SkinFallback(); + } ); + + $skin = $factory->makeSkin( 'testfallback' ); + $this->assertInstanceOf( 'Skin', $skin ); + $this->assertInstanceOf( 'SkinFallback', $skin ); + } + + /** + * @covers SkinFactory::getSkinNames + */ + public function testGetSkinNames() { + $factory = new SkinFactory(); + // A fake callback we can use that will never be called + $callback = function () { + // NOP + }; + $factory->register( 'skin1', 'Skin1', $callback ); + $factory->register( 'skin2', 'Skin2', $callback ); + $names = $factory->getSkinNames(); + $this->assertArrayHasKey( 'skin1', $names ); + $this->assertArrayHasKey( 'skin2', $names ); + $this->assertEquals( 'Skin1', $names['skin1'] ); + $this->assertEquals( 'Skin2', $names['skin2'] ); + } +} diff --git a/tests/phpunit/includes/skins/SkinTemplateTest.php b/tests/phpunit/includes/skins/SkinTemplateTest.php new file mode 100644 index 00000000..baa995d4 --- /dev/null +++ b/tests/phpunit/includes/skins/SkinTemplateTest.php @@ -0,0 +1,43 @@ +<?php + +/** + * @covers SkinTemplate + * + * @group Output + * + * @licence GNU GPL v2+ + * @author Bene* < benestar.wikimedia@gmail.com > + */ + +class SkinTemplateTest extends MediaWikiTestCase { + /** + * @dataProvider makeListItemProvider + */ + public function testMakeListItem( $expected, $key, $item, $options, $message ) { + $template = $this->getMockForAbstractClass( 'BaseTemplate' ); + + $this->assertEquals( + $expected, + $template->makeListItem( $key, $item, $options ), + $message + ); + } + + public function makeListItemProvider() { + return array( + array( + '<li class="class" title="itemtitle"><a href="url" title="title">text</a></li>', + '', + array( + 'class' => 'class', + 'itemtitle' => 'itemtitle', + 'href' => 'url', + 'title' => 'title', + 'text' => 'text' + ), + array(), + 'Test makteListItem with normal values' + ) + ); + } +} diff --git a/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php new file mode 100644 index 00000000..779fa558 --- /dev/null +++ b/tests/phpunit/includes/specialpage/SpecialPageFactoryTest.php @@ -0,0 +1,225 @@ +<?php +/** + * Factory for handling the special page list and generating SpecialPage objects. + * + * 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 + * + * @covers SpecialPageFactory + * @group SpecialPage + */ +class SpecialPageFactoryTest extends MediaWikiTestCase { + + protected function tearDown() { + parent::tearDown(); + + SpecialPageFactory::resetList(); + } + + public function newSpecialAllPages() { + return new SpecialAllPages(); + } + + public function specialPageProvider() { + return array( + 'class name' => array( 'SpecialAllPages', false ), + 'closure' => array( function() { + return new SpecialAllPages(); + }, false ), + 'function' => array( array( $this, 'newSpecialAllPages' ), false ), + ); + } + + /** + * @dataProvider specialPageProvider + */ + public function testGetPage( $spec, $shouldReuseInstance ) { + $this->mergeMwGlobalArrayValue( 'wgSpecialPages', array( 'testdummy' => $spec ) ); + SpecialPageFactory::resetList(); + + $page = SpecialPageFactory::getPage( 'testdummy' ); + $this->assertInstanceOf( 'SpecialPage', $page ); + + $page2 = SpecialPageFactory::getPage( 'testdummy' ); + $this->assertEquals( $shouldReuseInstance, $page2 === $page, "Should re-use instance:" ); + } + + public function testGetNames() { + $this->mergeMwGlobalArrayValue( 'wgSpecialPages', array( 'testdummy' => 'SpecialAllPages' ) ); + SpecialPageFactory::resetList(); + + $names = SpecialPageFactory::getNames(); + $this->assertInternalType( 'array', $names ); + $this->assertContains( 'testdummy', $names ); + } + + public function testResolveAlias() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + list( $name, $param ) = SpecialPageFactory::resolveAlias( 'Spezialseiten/Foo' ); + $this->assertEquals( 'Specialpages', $name ); + $this->assertEquals( 'Foo', $param ); + } + + public function testGetLocalNameFor() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + $name = SpecialPageFactory::getLocalNameFor( 'Specialpages', 'Foo' ); + $this->assertEquals( 'Spezialseiten/Foo', $name ); + } + + public function testGetTitleForAlias() { + $this->setMwGlobals( 'wgContLang', Language::factory( 'de' ) ); + SpecialPageFactory::resetList(); + + $title = SpecialPageFactory::getTitleForAlias( 'Specialpages/Foo' ); + $this->assertEquals( 'Spezialseiten/Foo', $title->getText() ); + $this->assertEquals( NS_SPECIAL, $title->getNamespace() ); + } + + /** + * @dataProvider provideTestConflictResolution + */ + public function testConflictResolution( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ) { + global $wgContLang; + $lang = clone $wgContLang; + $lang->mExtendedSpecialPageAliases = $aliasesList; + $this->setMwGlobals( 'wgContLang', $lang ); + $this->setMwGlobals( 'wgSpecialPages', + array_combine( array_keys( $aliasesList ), array_keys( $aliasesList ) ) + ); + SpecialPageFactory::resetList(); + + // Catch the warnings we expect to be raised + $warnings = array(); + $this->setMwGlobals( 'wgDevelopmentWarnings', true ); + set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) { + if ( preg_match( '/First alias \'[^\']*\' for .*/', $errstr ) || + preg_match( '/Did not find a usable alias for special page .*/', $errstr ) + ) { + $warnings[] = $errstr; + return true; + } + return false; + } ); + $reset = new ScopedCallback( 'restore_error_handler' ); + + list( $name, /*...*/ ) = SpecialPageFactory::resolveAlias( $alias ); + $this->assertEquals( $expectedName, $name, "$test: Alias to name" ); + $result = SpecialPageFactory::getLocalNameFor( $name ); + $this->assertEquals( $expectedAlias, $result, "$test: Alias to name to alias" ); + + $gotWarnings = count( $warnings ); + if ( $gotWarnings !== $expectWarnings ) { + $this->fail( "Expected $expectWarnings warning(s), but got $gotWarnings:\n" . + join( "\n", $warnings ) + ); + } + } + + /** + * @dataProvider provideTestConflictResolution + */ + public function testConflictResolutionReversed( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ) { + // Make sure order doesn't matter by reversing the list + $aliasesList = array_reverse( $aliasesList ); + return $this->testConflictResolution( + $test, $aliasesList, $alias, $expectedName, $expectedAlias, $expectWarnings + ); + } + + public function provideTestConflictResolution() { + return array( + array( + 'Canonical name wins', + array( 'Foo' => array( 'Foo', 'Bar' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Foo', + 'Foo', + 'Foo', + 1, + ), + + array( + 'Doesn\'t redirect to a different special page\'s canonical name', + array( 'Foo' => array( 'Foo', 'Bar' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'Canonical name wins even if not aliased', + array( 'Foo' => array( 'FooPage' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Foo', + 'Foo', + 'FooPage', + 1, + ), + + array( + 'Doesn\'t redirect to a different special page\'s canonical name even if not aliased', + array( 'Foo' => array( 'FooPage' ), 'Baz' => array( 'Foo', 'BazPage', 'Baz2' ) ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'First local name beats non-first', + array( 'First' => array( 'Foo' ), 'NonFirst' => array( 'Bar', 'Foo' ) ), + 'Foo', + 'First', + 'Foo', + 0, + ), + + array( + 'Doesn\'t redirect to a different special page\'s first alias', + array( + 'Foo' => array( 'Foo' ), + 'First' => array( 'Bar' ), + 'Baz' => array( 'Foo', 'Bar', 'BazPage', 'Baz2' ) + ), + 'Baz', + 'Baz', + 'BazPage', + 1, + ), + + array( + 'Doesn\'t redirect wrong even if all aliases conflict', + array( + 'Foo' => array( 'Foo' ), + 'First' => array( 'Bar' ), + 'Baz' => array( 'Foo', 'Bar' ) + ), + 'Baz', + 'Baz', + 'Baz', + 2, + ), + + ); + } + +} diff --git a/tests/phpunit/includes/specials/ImageListPagerTest.php b/tests/phpunit/includes/specials/ImageListPagerTest.php new file mode 100644 index 00000000..22bdefdf --- /dev/null +++ b/tests/phpunit/includes/specials/ImageListPagerTest.php @@ -0,0 +1,22 @@ +<?php +/** + * Test class for ImageListPagerTest class. + * + * Copyright © 2013, Antoine Musso + * Copyright © 2013, Siebrand Mazeland + * Copyright © 2013, Wikimedia Foundation Inc. + * + * @group Database + */ + +class ImageListPagerTest extends MediaWikiTestCase { + /** + * @expectedException MWException + * @expectedExceptionMessage invalid_field + * @covers ImageListPager::formatValue + */ + public function testFormatValuesThrowException() { + $page = new ImageListPager( RequestContext::getMain() ); + $page->formatValue( 'invalid_field', 'invalid_value' ); + } +} diff --git a/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php new file mode 100644 index 00000000..f92dc66f --- /dev/null +++ b/tests/phpunit/includes/specials/QueryAllSpecialPagesTest.php @@ -0,0 +1,74 @@ +<?php +/** + * Test class to run the query of most of all our special pages + * + * Copyright © 2011, Antoine Musso + * + * @author Antoine Musso + * @group Database + */ + +/** + * @covers QueryPage<extended> + */ +class QueryAllSpecialPagesTest extends MediaWikiTestCase { + + /** List query pages that can not be tested automatically */ + protected $manualTest = array( + 'LinkSearchPage' + ); + + /** + * Pages whose query use the same DB table more than once. + * This is used to skip testing those pages when run against a MySQL backend + * which does not support reopening a temporary table. See upstream bug: + * http://bugs.mysql.com/bug.php?id=10327 + */ + protected $reopensTempTable = array( + 'BrokenRedirects', + ); + + /** + * Initialize all query page objects + */ + function __construct() { + parent::__construct(); + + foreach ( QueryPage::getPages() as $page ) { + $class = $page[0]; + if ( !in_array( $class, $this->manualTest ) ) { + $this->queryPages[$class] = new $class; + } + } + } + + /** + * Test SQL for each of our QueryPages objects + * @group Database + */ + public function testQuerypageSqlQuery() { + global $wgDBtype; + + foreach ( $this->queryPages as $page ) { + // With MySQL, skips special pages reopening a temporary table + // See http://bugs.mysql.com/bug.php?id=10327 + if ( + $wgDBtype === 'mysql' + && in_array( $page->getName(), $this->reopensTempTable ) + ) { + $this->markTestSkipped( "SQL query for page {$page->getName()} " + . "can not be tested on MySQL backend (it reopens a temporary table)" ); + continue; + } + + $msg = "SQL query for page {$page->getName()} should give a result wrapper object"; + + $result = $page->reallyDoQuery( 50 ); + if ( $result instanceof ResultWrapper ) { + $this->assertTrue( true, $msg ); + } else { + $this->assertFalse( false, $msg ); + } + } + } +} diff --git a/tests/phpunit/includes/specials/SpecialMIMESearchTest.php b/tests/phpunit/includes/specials/SpecialMIMESearchTest.php new file mode 100644 index 00000000..14d19685 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMIMESearchTest.php @@ -0,0 +1,48 @@ +<?php +/** + * @group Database + */ + +class SpecialMIMESearchTest extends MediaWikiTestCase { + + /** @var MIMESearchPage */ + private $page; + + function setUp() { + $this->page = new MIMESearchPage; + $context = new RequestContext(); + $context->setTitle( Title::makeTitle( NS_SPECIAL, 'MIMESearch' ) ); + $context->setRequest( new FauxRequest() ); + $this->page->setContext( $context ); + + parent::setUp(); + } + + /** + * @dataProvider providerMimeFiltering + * @param string $par Subpage for special page + * @param string $major Major MIME type we expect to look for + * @param string $minor Minor MIME type we expect to look for + */ + function testMimeFiltering( $par, $major, $minor ) { + $this->page->run( $par ); + $qi = $this->page->getQueryInfo(); + $this->assertEquals( $qi['conds']['img_major_mime'], $major ); + if ( $minor !== null ) { + $this->assertEquals( $qi['conds']['img_minor_mime'], $minor ); + } else { + $this->assertArrayNotHasKey( 'img_minor_mime', $qi['conds'] ); + } + $this->assertContains( 'image', $qi['tables'] ); + } + + function providerMimeFiltering() { + return array( + array( 'image/gif', 'image', 'gif' ), + array( 'image/png', 'image', 'png' ), + array( 'application/pdf', 'application', 'pdf' ), + array( 'image/*', 'image', null ), + array( 'multipart/*', 'multipart', null ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialMyLanguageTest.php b/tests/phpunit/includes/specials/SpecialMyLanguageTest.php new file mode 100644 index 00000000..4dbfc412 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialMyLanguageTest.php @@ -0,0 +1,65 @@ +<?php + +/** + * @group Database + * @covers SpecialMyLanguage + */ +class SpecialMyLanguageTest extends MediaWikiTestCase { + public function addDBData() { + $titles = array( + 'Page/Another', + 'Page/Another/ru', + ); + foreach ( $titles as $title ) { + $page = WikiPage::factory( Title::newFromText( $title ) ); + if ( $page->getId() == 0 ) { + $page->doEditContent( + new WikitextContent( 'UTContent' ), + 'UTPageSummary', + EDIT_NEW, + false, + User::newFromName( 'UTSysop' ) ); + } + } + } + + /** + * @covers SpecialMyLanguage::findTitle + * @dataProvider provideFindTitle + * @param string $expected + * @param string $subpage + * @param string $langCode + * @param string $userLang + */ + public function testFindTitle( $expected, $subpage, $langCode, $userLang ) { + $this->setMwGlobals( 'wgLanguageCode', $langCode ); + $special = new SpecialMyLanguage(); + $special->getContext()->setLanguage( $userLang ); + // Test with subpages both enabled and disabled + $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', array( NS_MAIN => true ) ); + $this->assertTitle( $expected, $special->findTitle( $subpage ) ); + $this->mergeMwGlobalArrayValue( 'wgNamespacesWithSubpages', array( NS_MAIN => false ) ); + $this->assertTitle( $expected, $special->findTitle( $subpage ) ); + } + + /** + * @param string $expected + * @param Title|null $title + */ + private function assertTitle( $expected, $title ) { + if ( $title ) { + $title = $title->getPrefixedText(); + } + $this->assertEquals( $expected, $title ); + } + + public static function provideFindTitle() { + return array( + array( null, '::Fail', 'en', 'en' ), + array( 'Page/Another', 'Page/Another/en', 'en', 'en' ), + array( 'Page/Another', 'Page/Another', 'en', 'en' ), + array( 'Page/Another/ru', 'Page/Another', 'en', 'ru' ), + array( 'Page/Another', 'Page/Another', 'en', 'es' ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialPreferencesTest.php b/tests/phpunit/includes/specials/SpecialPreferencesTest.php new file mode 100644 index 00000000..4f6c4116 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialPreferencesTest.php @@ -0,0 +1,57 @@ +<?php +/** + * Test class for SpecialPreferences class. + * + * Copyright © 2013, Antoine Musso + * Copyright © 2013, Wikimedia Foundation Inc. + * + */ + +/** + * @covers SpecialPreferences + */ +class SpecialPreferencesTest extends MediaWikiTestCase { + + /** + * Make sure a nickname which is longer than $wgMaxSigChars + * is not throwing a fatal error. + * + * Test specifications by Alexandre "ialex" Emsenhuber. + * @todo give this test a real name explaining what is being tested here + */ + public function testBug41337() { + + // Set a low limit + $this->setMwGlobals( 'wgMaxSigChars', 2 ); + + $user = $this->getMock( 'User' ); + $user->expects( $this->any() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( false ) ); + + # Yeah foreach requires an array, not NULL =( + $user->expects( $this->any() ) + ->method( 'getEffectiveGroups' ) + ->will( $this->returnValue( array() ) ); + + # The mocked user has a long nickname + $user->expects( $this->any() ) + ->method( 'getOption' ) + ->will( $this->returnValueMap( array( + array( 'nickname', null, false, 'superlongnickname' ), + ) + ) ); + + # Forge a request to call the special page + $context = new RequestContext(); + $context->setRequest( new FauxRequest() ); + $context->setUser( $user ); + $context->setTitle( Title::newFromText( 'Test' ) ); + + # Do the call, should not spurt a fatal error. + $special = new SpecialPreferences(); + $special->setContext( $context ); + $this->assertNull( $special->execute( array() ) ); + } + +} diff --git a/tests/phpunit/includes/specials/SpecialRecentchangesTest.php b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php new file mode 100644 index 00000000..c3d75aa5 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialRecentchangesTest.php @@ -0,0 +1,123 @@ +<?php +/** + * Test class for SpecialRecentchanges class + * + * Copyright © 2011, Antoine Musso + * + * @author Antoine Musso + * @group Database + * + * @covers SpecialRecentChanges + */ +class SpecialRecentchangesTest extends MediaWikiTestCase { + + /** + * @var SpecialRecentChanges + */ + protected $rc; + + /** helper to test SpecialRecentchanges::buildMainQueryConds() */ + private function assertConditions( $expected, $requestOptions = null, $message = '' ) { + $context = new RequestContext; + $context->setRequest( new FauxRequest( $requestOptions ) ); + + # setup the rc object + $this->rc = new SpecialRecentChanges(); + $this->rc->setContext( $context ); + $formOptions = $this->rc->setup( null ); + + # Filter out rc_timestamp conditions which depends on the test runtime + # This condition is not needed as of march 2, 2011 -- hashar + # @todo FIXME: Find a way to generate the correct rc_timestamp + $queryConditions = array_filter( + $this->rc->buildMainQueryConds( $formOptions ), + 'SpecialRecentchangesTest::filterOutRcTimestampCondition' + ); + + $this->assertEquals( + $expected, + $queryConditions, + $message + ); + } + + /** return false if condition begin with 'rc_timestamp ' */ + private static function filterOutRcTimestampCondition( $var ) { + return ( false === strpos( $var, 'rc_timestamp ' ) ); + } + + public function testRcNsFilter() { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => "rc_namespace = '0'", + ), + array( + 'namespace' => NS_MAIN, + ), + "rc conditions with no options (aka default setting)" + ); + } + + public function testRcNsFilterInversion() { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "rc_namespace != '%s'", NS_MAIN ), + ), + array( + 'namespace' => NS_MAIN, + 'invert' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * @bug 2429 + * @dataProvider provideNamespacesAssociations + */ + public function testRcNsFilterAssociation( $ns1, $ns2 ) { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "(rc_namespace = '%s' OR rc_namespace = '%s')", $ns1, $ns2 ), + ), + array( + 'namespace' => $ns1, + 'associated' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * @bug 2429 + * @dataProvider provideNamespacesAssociations + */ + public function testRcNsFilterAssociationWithInversion( $ns1, $ns2 ) { + $this->assertConditions( + array( # expected + 'rc_bot' => 0, + 0 => sprintf( "(rc_namespace != '%s' AND rc_namespace != '%s')", $ns1, $ns2 ), + ), + array( + 'namespace' => $ns1, + 'associated' => 1, + 'invert' => 1, + ), + "rc conditions with namespace inverted" + ); + } + + /** + * Provides associated namespaces to test recent changes + * namespaces association filtering. + */ + public static function provideNamespacesAssociations() { + return array( # (NS => Associated_NS) + array( NS_MAIN, NS_TALK ), + array( NS_TALK, NS_MAIN ), + ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php new file mode 100644 index 00000000..83489c65 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -0,0 +1,144 @@ +<?php +/** + * Test class for SpecialSearch class + * Copyright © 2012, Antoine Musso + * + * @author Antoine Musso + * @group Database + */ + +class SpecialSearchTest extends MediaWikiTestCase { + + /** + * @covers SpecialSearch::load + * @dataProvider provideSearchOptionsTests + * @param array $requested Request parameters. For example: + * array( 'ns5' => true, 'ns6' => true). Null to use default options. + * @param array $userOptions User options to test with. For example: + * array('searchNs5' => 1 );. Null to use default options. + * @param string $expectedProfile An expected search profile name + * @param array $expectedNS Expected namespaces + * @param string $message + */ + public function testProfileAndNamespaceLoading( $requested, $userOptions, + $expectedProfile, $expectedNS, $message = 'Profile name and namespaces mismatches!' + ) { + $context = new RequestContext; + $context->setUser( + $this->newUserWithSearchNS( $userOptions ) + ); + /* + $context->setRequest( new FauxRequest( array( + 'ns5'=>true, + 'ns6'=>true, + ) )); + */ + $context->setRequest( new FauxRequest( $requested ) ); + $search = new SpecialSearch(); + $search->setContext( $context ); + $search->load(); + + /** + * Verify profile name and namespace in the same assertion to make + * sure we will be able to fully compare the above code. PHPUnit stop + * after an assertion fail. + */ + $this->assertEquals( + array( /** Expected: */ + 'ProfileName' => $expectedProfile, + 'Namespaces' => $expectedNS, + ), + array( /** Actual: */ + 'ProfileName' => $search->getProfile(), + 'Namespaces' => $search->getNamespaces(), + ), + $message + ); + } + + public static function provideSearchOptionsTests() { + $defaultNS = SearchEngine::defaultNamespaces(); + $EMPTY_REQUEST = array(); + $NO_USER_PREF = null; + + return array( + /** + * Parameters: + * <Web Request>, <User options> + * Followed by expected values: + * <ProfileName>, <NSList> + * Then an optional message. + */ + array( + $EMPTY_REQUEST, $NO_USER_PREF, + 'default', $defaultNS, + 'Bug 33270: No request nor user preferences should give default profile' + ), + array( + array( 'ns5' => 1 ), $NO_USER_PREF, + 'advanced', array( 5 ), + 'Web request with specific NS should override user preference' + ), + array( + $EMPTY_REQUEST, array( + 'searchNs2' => 1, + 'searchNs14' => 1, + ) + array_fill_keys( array_map( function ( $ns ) { + return "searchNs$ns"; + }, $defaultNS ), 0 ), + 'advanced', array( 2, 14 ), + 'Bug 33583: search with no option should honor User search preferences' + . ' and have all other namespace disabled' + ), + ); + } + + /** + * Helper to create a new User object with given options + * User remains anonymous though + * @param array|null $opt + */ + function newUserWithSearchNS( $opt = null ) { + $u = User::newFromId( 0 ); + if ( $opt === null ) { + return $u; + } + foreach ( $opt as $name => $value ) { + $u->setOption( $name, $value ); + } + + return $u; + } + + /** + * Verify we do not expand search term in <title> on search result page + * https://gerrit.wikimedia.org/r/4841 + */ + public function testSearchTermIsNotExpanded() { + $this->setMwGlobals( array( + 'wgSearchType' => null, + ) ); + + # Initialize [[Special::Search]] + $search = new SpecialSearch(); + $search->getContext()->setTitle( Title::newFromText( 'Special:Search' ) ); + $search->load(); + + # Simulate a user searching for a given term + $term = '{{SITENAME}}'; + $search->showResults( $term ); + + # Lookup the HTML page title set for that page + $pageTitle = $search + ->getContext() + ->getOutput() + ->getHTMLTitle(); + + # Compare :-] + $this->assertRegExp( + '/' . preg_quote( $term ) . '/', + $pageTitle, + "Search term '{$term}' should not be expanded in Special:Search <title>" + ); + } +} diff --git a/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php b/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php new file mode 100644 index 00000000..4171c10e --- /dev/null +++ b/tests/phpunit/includes/title/MediaWikiPageLinkRendererTest.php @@ -0,0 +1,169 @@ +<?php +/** + * 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 + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers MediaWikiPageLinkRenderer + * + * @group Title + * @group Database + */ +class MediaWikiPageLinkRendererTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgContLang' => Language::factory( 'en' ), + ) ); + } + + /** + * Returns a mock GenderCache that will return "female" always. + * + * @return GenderCache + */ + private function getGenderCache() { + $genderCache = $this->getMockBuilder( 'GenderCache' ) + ->disableOriginalConstructor() + ->getMock(); + + $genderCache->expects( $this->any() ) + ->method( 'getGenderOf' ) + ->will( $this->returnValue( 'female' ) ); + + return $genderCache; + } + + public static function provideGetPageUrl() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + array(), + '/Foo_Bar' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + array( 'foo' => 'bar' ), + '/User:Hansi_Maier?foo=bar#stuff' + ), + ); + } + + /** + * @dataProvider provideGetPageUrl + */ + public function testGetPageUrl( TitleValue $title, $params, $url ) { + // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the + // WikitextTitleFormatter we pass here, and relies on the Linker + // class for generating the link! This may break the test e.g. + // of Linker uses a different language for the namespace names. + + $lang = Language::factory( 'en' ); + + $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); + $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); + $actual = $renderer->getPageUrl( $title, $params ); + + $this->assertEquals( $url, $actual ); + } + + public static function provideRenderHtmlLink() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + 'Foo Bar', + '!<a .*href=".*?Foo_Bar.*?".*?>Foo Bar</a>!' + ), + array( + //NOTE: Linker doesn't include fragments in "broken" links + //NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + 'Hansi Maier\'s Stuff', + '!<a .*href=".*?User:Hansi_Maier.*?>Hansi Maier\'s Stuff</a>!' + ), + array( + //NOTE: Linker doesn't include fragments in "broken" links + //NOTE: once this no longer uses Linker, we will get "2" instead of "User" for the namespace. + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + null, + '!<a .*href=".*?User:Hansi_Maier.*?>User:Hansi Maier#stuff</a>!' + ), + ); + } + + /** + * @dataProvider provideRenderHtmlLink + */ + public function testRenderHtmlLink( TitleValue $title, $text, $pattern ) { + // NOTE: was of Feb 2014, MediaWikiPageLinkRenderer *ignores* the + // WikitextTitleFormatter we pass here, and relies on the Linker + // class for generating the link! This may break the test e.g. + // of Linker uses a different language for the namespace names. + + $lang = Language::factory( 'en' ); + + $formatter = new MediaWikiTitleCodec( $lang, $this->getGenderCache() ); + $renderer = new MediaWikiPageLinkRenderer( $formatter ); + $actual = $renderer->renderHtmlLink( $title, $text ); + + $this->assertRegExp( $pattern, $actual ); + } + + public static function provideRenderWikitextLink() { + return array( + array( + new TitleValue( NS_MAIN, 'Foo_Bar' ), + 'Foo Bar', + '[[:0:Foo Bar|Foo Bar]]' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + 'Hansi Maier\'s Stuff', + '[[:2:Hansi Maier#stuff|Hansi Maier's Stuff]]' + ), + array( + new TitleValue( NS_USER, 'Hansi_Maier', 'stuff' ), + null, + '[[:2:Hansi Maier#stuff|2:Hansi Maier#stuff]]' + ), + ); + } + + /** + * @dataProvider provideRenderWikitextLink + */ + public function testRenderWikitextLink( TitleValue $title, $text, $expected ) { + $formatter = $this->getMock( 'TitleFormatter' ); + $formatter->expects( $this->any() ) + ->method( 'getFullText' ) + ->will( $this->returnCallback( + function ( TitleValue $title ) { + return str_replace( '_', ' ', "$title" ); + } + )); + + $renderer = new MediaWikiPageLinkRenderer( $formatter, '/' ); + $actual = $renderer->renderWikitextLink( $title, $text ); + + $this->assertEquals( $expected, $actual ); + } +} diff --git a/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php new file mode 100644 index 00000000..f95b3050 --- /dev/null +++ b/tests/phpunit/includes/title/MediaWikiTitleCodecTest.php @@ -0,0 +1,384 @@ +<?php +/** + * 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 + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers MediaWikiTitleCodec + * + * @group Title + * @group Database + * ^--- needed because of global state in + */ +class MediaWikiTitleCodecTest extends MediaWikiTestCase { + + public function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgLanguageCode' => 'en', + 'wgContLang' => Language::factory( 'en' ), + // User language + 'wgLang' => Language::factory( 'en' ), + 'wgAllowUserJs' => false, + 'wgDefaultLanguageVariant' => false, + 'wgLocalInterwikis' => array( 'localtestiw' ), + 'wgCapitalLinks' => true, + + // NOTE: this is why global state is evil. + // TODO: refactor access to the interwiki codes so it can be injected. + 'wgHooks' => array( + 'InterwikiLoadPrefix' => array( + function ( $prefix, &$data ) { + if ( $prefix === 'localtestiw' ) { + $data = array( 'iw_url' => 'localtestiw' ); + } elseif ( $prefix === 'remotetestiw' ) { + $data = array( 'iw_url' => 'remotetestiw' ); + } + return false; + } + ) + ) + ) ); + } + + /** + * Returns a mock GenderCache that will consider a user "female" if the + * first part of the user name ends with "a". + * + * @return GenderCache + */ + private function getGenderCache() { + $genderCache = $this->getMockBuilder( 'GenderCache' ) + ->disableOriginalConstructor() + ->getMock(); + + $genderCache->expects( $this->any() ) + ->method( 'getGenderOf' ) + ->will( $this->returnCallback( function ( $userName ) { + return preg_match( '/^[^- _]+a( |_|$)/u', $userName ) ? 'female' : 'male'; + } ) ); + + return $genderCache; + } + + protected function makeCodec( $lang ) { + $gender = $this->getGenderCache(); + $lang = Language::factory( $lang ); + return new MediaWikiTitleCodec( $lang, $gender ); + } + + public static function provideFormat() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier#stuff and so on' ), + array( false, 'Hansi_Maier', '', 'en', 'Hansi Maier' ), + array( + NS_USER_TALK, + 'hansi__maier', + '', + 'en', + 'User talk:hansi maier', + 'User talk:Hansi maier' + ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ), + ); + } + + /** + * @dataProvider provideFormat + */ + public function testFormat( $namespace, $text, $fragment, $lang, $expected, $normalized = null ) { + if ( $normalized === null ) { + $normalized = $expected; + } + + $codec = $this->makeCodec( $lang ); + $actual = $codec->formatTitle( $namespace, $text, $fragment ); + + $this->assertEquals( $expected, $actual, 'formatted' ); + + // test round trip + $parsed = $codec->parseTitle( $actual, NS_MAIN ); + $actual2 = $codec->formatTitle( + $parsed->getNamespace(), + $parsed->getText(), + $parsed->getFragment() + ); + + $this->assertEquals( $normalized, $actual2, 'normalized after round trip' ); + } + + public static function provideGetText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'Hansi Maier' ), + ); + } + + /** + * @dataProvider provideGetText + */ + public function testGetText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetPrefixedText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier' ), + + // No capitalization or normalization is applied while formatting! + array( NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ), + ); + } + + /** + * @dataProvider provideGetPrefixedText + */ + public function testGetPrefixedText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getPrefixedText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetFullText() { + return array( + array( NS_MAIN, 'Foo_Bar', '', 'en', 'Foo Bar' ), + array( NS_USER, 'Hansi_Maier', 'stuff_and_so_on', 'en', 'User:Hansi Maier#stuff and so on' ), + + // No capitalization or normalization is applied while formatting! + array( NS_USER_TALK, 'hansi__maier', '', 'en', 'User talk:hansi maier' ), + ); + } + + /** + * @dataProvider provideGetFullText + */ + public function testGetFullText( $namespace, $dbkey, $fragment, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $title = new TitleValue( $namespace, $dbkey, $fragment ); + + $actual = $codec->getFullText( $title ); + + $this->assertEquals( $expected, $actual ); + } + + public static function provideParseTitle() { + //TODO: test capitalization and trimming + //TODO: test unicode normalization + + return array( + array( ' : Hansi_Maier _ ', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'Hansi_Maier', '' ) ), + array( 'User:::1', NS_MAIN, 'de', + new TitleValue( NS_USER, '0:0:0:0:0:0:0:1', '' ) ), + array( ' lisa Müller', NS_USER, 'de', + new TitleValue( NS_USER, 'Lisa_Müller', '' ) ), + array( 'benutzerin:lisa Müller#stuff', NS_MAIN, 'de', + new TitleValue( NS_USER, 'Lisa_Müller', 'stuff' ) ), + + array( ':Category:Quux', NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Category:Quux', NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Category:Quux', NS_CATEGORY, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( 'Quux', NS_CATEGORY, 'en', + new TitleValue( NS_CATEGORY, 'Quux', '' ) ), + array( ':Quux', NS_CATEGORY, 'en', + new TitleValue( NS_MAIN, 'Quux', '' ) ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + + array( 'a b c', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'A_b_c' ) ), + array( ' a b c ', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'A_b_c' ) ), + array( ' _ Foo __ Bar_ _', NS_MAIN, 'en', + new TitleValue( NS_MAIN, 'Foo_Bar' ) ), + + //NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync. + array( 'Sandbox', NS_MAIN, 'en', ), + array( 'A "B"', NS_MAIN, 'en', ), + array( 'A \'B\'', NS_MAIN, 'en', ), + array( '.com', NS_MAIN, 'en', ), + array( '~', NS_MAIN, 'en', ), + array( '"', NS_MAIN, 'en', ), + array( '\'', NS_MAIN, 'en', ), + + array( 'Talk:Sandbox', NS_MAIN, 'en', + new TitleValue( NS_TALK, 'Sandbox' ) ), + array( 'Talk:Foo:Sandbox', NS_MAIN, 'en', + new TitleValue( NS_TALK, 'Foo:Sandbox' ) ), + array( 'File:Example.svg', NS_MAIN, 'en', + new TitleValue( NS_FILE, 'Example.svg' ) ), + array( 'File_talk:Example.svg', NS_MAIN, 'en', + new TitleValue( NS_FILE_TALK, 'Example.svg' ) ), + array( 'Foo/.../Sandbox', NS_MAIN, 'en', + 'Foo/.../Sandbox' ), + array( 'Sandbox/...', NS_MAIN, 'en', + 'Sandbox/...' ), + array( 'A~~', NS_MAIN, 'en', + 'A~~' ), + // Length is 256 total, but only title part matters + array( 'Category:' . str_repeat( 'x', 248 ), NS_MAIN, 'en', + new TitleValue( NS_CATEGORY, + 'X' . str_repeat( 'x', 247 ) ) ), + array( str_repeat( 'x', 252 ), NS_MAIN, 'en', + 'X' . str_repeat( 'x', 251 ) ) + ); + } + + /** + * @dataProvider provideParseTitle + */ + public function testParseTitle( $text, $ns, $lang, $title = null ) { + if ( $title === null ) { + $title = str_replace( ' ', '_', trim( $text ) ); + } + + if ( is_string( $title ) ) { + $title = new TitleValue( NS_MAIN, $title, '' ); + } + + $codec = $this->makeCodec( $lang ); + $actual = $codec->parseTitle( $text, $ns ); + + $this->assertEquals( $title, $actual ); + } + + public static function provideParseTitle_invalid() { + //TODO: test unicode errors + + return array( + array( '#' ), + array( '::' ), + array( '::xx' ), + array( '::##' ), + array( ' :: x' ), + + array( 'Talk:File:Foo.jpg' ), + array( 'Talk:localtestiw:Foo' ), + array( 'remotetestiw:Foo' ), + array( '::1' ), // only valid in user namespace + array( 'User::x' ), // leading ":" in a user name is only valid of IPv6 addresses + + //NOTE: cases copied from TitleTest::testSecureAndSplit. Keep in sync. + array( '' ), + array( ':' ), + array( '__ __' ), + array( ' __ ' ), + // Bad characters forbidden regardless of wgLegalTitleChars + array( 'A [ B' ), + array( 'A ] B' ), + array( 'A { B' ), + array( 'A } B' ), + array( 'A < B' ), + array( 'A > B' ), + array( 'A | B' ), + // URL encoding + array( 'A%20B' ), + array( 'A%23B' ), + array( 'A%2523B' ), + // XML/HTML character entity references + // Note: Commented out because they are not marked invalid by the PHP test as + // Title::newFromText runs Sanitizer::decodeCharReferencesAndNormalize first. + //array( 'A é B' ), + //array( 'A é B' ), + //array( 'A é B' ), + // Subject of NS_TALK does not roundtrip to NS_MAIN + array( 'Talk:File:Example.svg' ), + // Directory navigation + array( '.' ), + array( '..' ), + array( './Sandbox' ), + array( '../Sandbox' ), + array( 'Foo/./Sandbox' ), + array( 'Foo/../Sandbox' ), + array( 'Sandbox/.' ), + array( 'Sandbox/..' ), + // Tilde + array( 'A ~~~ Name' ), + array( 'A ~~~~ Signature' ), + array( 'A ~~~~~ Timestamp' ), + array( str_repeat( 'x', 256 ) ), + // Namespace prefix without actual title + array( 'Talk:' ), + array( 'Category: ' ), + array( 'Category: #bar' ) + ); + } + + /** + * @dataProvider provideParseTitle_invalid + */ + public function testParseTitle_invalid( $text ) { + $this->setExpectedException( 'MalformedTitleException' ); + + $codec = $this->makeCodec( 'en' ); + $codec->parseTitle( $text, NS_MAIN ); + } + + public static function provideGetNamespaceName() { + return array( + array( NS_MAIN, 'Foo', 'en', '' ), + array( NS_USER, 'Foo', 'en', 'User' ), + array( NS_USER, 'Hansi Maier', 'de', 'Benutzer' ), + + // getGenderCache() provides a mock that considers first + // names ending in "a" to be female. + array( NS_USER, 'Lisa Müller', 'de', 'Benutzerin' ), + ); + } + + /** + * @dataProvider provideGetNamespaceName + * + * @param int $namespace + * @param string $text + * @param string $lang + * @param string $expected + * + * @internal param \TitleValue $title + */ + public function testGetNamespaceName( $namespace, $text, $lang, $expected ) { + $codec = $this->makeCodec( $lang ); + $name = $codec->getNamespaceName( $namespace, $text ); + + $this->assertEquals( $expected, $name ); + } +} diff --git a/tests/phpunit/includes/title/TitleValueTest.php b/tests/phpunit/includes/title/TitleValueTest.php new file mode 100644 index 00000000..3ba008d6 --- /dev/null +++ b/tests/phpunit/includes/title/TitleValueTest.php @@ -0,0 +1,100 @@ +<?php +/** + * 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 + * @license GPL 2+ + * @author Daniel Kinzler + */ + +/** + * @covers TitleValue + * + * @group Title + */ +class TitleValueTest extends MediaWikiTestCase { + + public function testConstruction() { + $title = new TitleValue( NS_USER, 'TestThis', 'stuff' ); + + $this->assertEquals( NS_USER, $title->getNamespace() ); + $this->assertEquals( 'TestThis', $title->getText() ); + $this->assertEquals( 'stuff', $title->getFragment() ); + } + + public function badConstructorProvider() { + return array( + array( 'foo', 'title', 'fragment' ), + array( null, 'title', 'fragment' ), + array( 2.3, 'title', 'fragment' ), + + array( NS_MAIN, 5, 'fragment' ), + array( NS_MAIN, null, 'fragment' ), + array( NS_MAIN, '', 'fragment' ), + array( NS_MAIN, 'foo bar', '' ), + array( NS_MAIN, 'bar_', '' ), + array( NS_MAIN, '_foo', '' ), + array( NS_MAIN, ' eek ', '' ), + + array( NS_MAIN, 'title', 5 ), + array( NS_MAIN, 'title', null ), + array( NS_MAIN, 'title', array() ), + ); + } + + /** + * @dataProvider badConstructorProvider + */ + public function testConstructionErrors( $ns, $text, $fragment ) { + $this->setExpectedException( 'InvalidArgumentException' ); + new TitleValue( $ns, $text, $fragment ); + } + + public function fragmentTitleProvider() { + return array( + array( new TitleValue( NS_MAIN, 'Test' ), 'foo' ), + array( new TitleValue( NS_TALK, 'Test', 'foo' ), '' ), + array( new TitleValue( NS_CATEGORY, 'Test', 'foo' ), 'bar' ), + ); + } + + /** + * @dataProvider fragmentTitleProvider + */ + public function testCreateFragmentTitle( TitleValue $title, $fragment ) { + $fragmentTitle = $title->createFragmentTitle( $fragment ); + + $this->assertEquals( $title->getNamespace(), $fragmentTitle->getNamespace() ); + $this->assertEquals( $title->getText(), $fragmentTitle->getText() ); + $this->assertEquals( $fragment, $fragmentTitle->getFragment() ); + } + + public function getTextProvider() { + return array( + array( 'Foo', 'Foo' ), + array( 'Foo_Bar', 'Foo Bar' ), + ); + } + + /** + * @dataProvider getTextProvider + */ + public function testGetText( $dbkey, $text ) { + $title = new TitleValue( NS_MAIN, $dbkey ); + + $this->assertEquals( $text, $title->getText() ); + } +} diff --git a/tests/phpunit/includes/upload/UploadBaseTest.php b/tests/phpunit/includes/upload/UploadBaseTest.php new file mode 100644 index 00000000..3d3b0068 --- /dev/null +++ b/tests/phpunit/includes/upload/UploadBaseTest.php @@ -0,0 +1,427 @@ +<?php + +/** + * @group Upload + */ +class UploadBaseTest extends MediaWikiTestCase { + + /** @var UploadTestHandler */ + protected $upload; + + protected function setUp() { + global $wgHooks; + parent::setUp(); + + $this->upload = new UploadTestHandler; + $this->hooks = $wgHooks; + $wgHooks['InterwikiLoadPrefix'][] = function ( $prefix, &$data ) { + return false; + }; + } + + protected function tearDown() { + global $wgHooks; + $wgHooks = $this->hooks; + + parent::tearDown(); + } + + /** + * First checks the return code + * of UploadBase::getTitle() and then the actual returned title + * + * @dataProvider provideTestTitleValidation + * @covers UploadBase::getTitle + */ + public function testTitleValidation( $srcFilename, $dstFilename, $code, $msg ) { + /* Check the result code */ + $this->assertEquals( $code, + $this->upload->testTitleValidation( $srcFilename ), + "$msg code" ); + + /* If we expect a valid title, check the title itself. */ + if ( $code == UploadBase::OK ) { + $this->assertEquals( $dstFilename, + $this->upload->getTitle()->getText(), + "$msg text" ); + } + } + + /** + * Test various forms of valid and invalid titles that can be supplied. + */ + public static function provideTestTitleValidation() { + return array( + /* Test a valid title */ + array( 'ValidTitle.jpg', 'ValidTitle.jpg', UploadBase::OK, + 'upload valid title' ), + /* A title with a slash */ + array( 'A/B.jpg', 'B.jpg', UploadBase::OK, + 'upload title with slash' ), + /* A title with illegal char */ + array( 'A:B.jpg', 'A-B.jpg', UploadBase::OK, + 'upload title with colon' ), + /* Stripping leading File: prefix */ + array( 'File:C.jpg', 'C.jpg', UploadBase::OK, + 'upload title with File prefix' ), + /* Test illegal suggested title (r94601) */ + array( '%281%29.JPG', null, UploadBase::ILLEGAL_FILENAME, + 'illegal title for upload' ), + /* A title without extension */ + array( 'A', null, UploadBase::FILETYPE_MISSING, + 'upload title without extension' ), + /* A title with no basename */ + array( '.jpg', null, UploadBase::MIN_LENGTH_PARTNAME, + 'upload title without basename' ), + /* A title that is longer than 255 bytes */ + array( str_repeat( 'a', 255 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG, + 'upload title longer than 255 bytes' ), + /* A title that is longer than 240 bytes */ + array( str_repeat( 'a', 240 ) . '.jpg', null, UploadBase::FILENAME_TOO_LONG, + 'upload title longer than 240 bytes' ), + ); + } + + /** + * Test the upload verification functions + * @covers UploadBase::verifyUpload + */ + public function testVerifyUpload() { + /* Setup with zero file size */ + $this->upload->initializePathInfo( '', '', 0 ); + $result = $this->upload->verifyUpload(); + $this->assertEquals( UploadBase::EMPTY_FILE, + $result['status'], + 'upload empty file' ); + } + + // Helper used to create an empty file of size $size. + private function createFileOfSize( $size ) { + $filename = tempnam( wfTempDir(), "mwuploadtest" ); + + $fh = fopen( $filename, 'w' ); + ftruncate( $fh, $size ); + fclose( $fh ); + + return $filename; + } + + /** + * test uploading a 100 bytes file with $wgMaxUploadSize = 100 + * + * This method should be abstracted so we can test different settings. + */ + public function testMaxUploadSize() { + global $wgMaxUploadSize; + $savedGlobal = $wgMaxUploadSize; // save global + global $wgFileExtensions; + $wgFileExtensions[] = 'txt'; + + $wgMaxUploadSize = 100; + + $filename = $this->createFileOfSize( $wgMaxUploadSize ); + $this->upload->initializePathInfo( basename( $filename ) . '.txt', $filename, 100 ); + $result = $this->upload->verifyUpload(); + unlink( $filename ); + + $this->assertEquals( + array( 'status' => UploadBase::OK ), $result ); + + $wgMaxUploadSize = $savedGlobal; // restore global + } + + + /** + * @dataProvider provideCheckSvgScriptCallback + */ + public function testCheckSvgScriptCallback( $svg, $wellFormed, $filterMatch, $message ) { + list( $formed, $match ) = $this->upload->checkSvgString( $svg ); + $this->assertSame( $wellFormed, $formed, $message ); + $this->assertSame( $filterMatch, $match, $message ); + } + + public static function provideCheckSvgScriptCallback() { + return array( + // html5sec SVG vectors + array( + '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>', + true, + true, + 'Script tag in svg (http://html5sec.org/#47)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"><g onload="javascript:alert(1)"></g></svg>', + true, + true, + 'SVG with onload property (http://html5sec.org/#11)' + ), + array( + '<svg onload="javascript:alert(1)" xmlns="http://www.w3.org/2000/svg"></svg>', + true, + true, + 'SVG with onload property (http://html5sec.org/#65)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="javascript:alert(1)"><rect width="1000" height="1000" fill="white"/></a> </svg>', + true, + true, + 'SVG with javascript xlink (http://html5sec.org/#87)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="data:application/xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4KPGRlZnM+CjxjaXJjbGUgaWQ9InRlc3QiIHI9IjUwIiBjeD0iMTAwIiBjeT0iMTAwIiBzdHlsZT0iZmlsbDogI0YwMCI+CjxzZXQgYXR0cmlidXRlTmFtZT0iZmlsbCIgYXR0cmlidXRlVHlwZT0iQ1NTIiBvbmJlZ2luPSdhbGVydChkb2N1bWVudC5jb29raWUpJwpvbmVuZD0nYWxlcnQoIm9uZW5kIiknIHRvPSIjMDBGIiBiZWdpbj0iMXMiIGR1cj0iNXMiIC8+CjwvY2lyY2xlPgo8L2RlZnM+Cjx1c2UgeGxpbms6aHJlZj0iI3Rlc3QiLz4KPC9zdmc+#test"/> </svg>', + true, + true, + 'SVG with Opera image xlink (http://html5sec.org/#88 - c)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="javascript:alert(1)"/> </svg>', + true, + true, + 'SVG with Opera animation xlink (http://html5sec.org/#88 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <animation xlink:href="data:text/xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>', + true, + true, + 'SVG with Opera animation xlink (http://html5sec.org/#88 - b)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' onload=\'alert(1)\'%3E%3C/svg%3E"/> </svg>', + true, + true, + 'SVG with Opera image xlink (http://html5sec.org/#88 - c)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="javascript:alert(1)"/> </svg>', + true, + true, + 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - d)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <foreignObject xlink:href="data:text/xml,%3Cscript xmlns=\'http://www.w3.org/1999/xhtml\'%3Ealert(1)%3C/script%3E"/> </svg>', + true, + true, + 'SVG with Opera foreignObject xlink (http://html5sec.org/#88 - e)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <set attributeName="onmouseover" to="alert(1)"/> </svg>', + true, + true, + 'SVG with event handler set (http://html5sec.org/#89 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <animate attributeName="onunload" to="alert(1)"/> </svg>', + true, + true, + 'SVG with event handler animate (http://html5sec.org/#89 - a)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <handler xmlns:ev="http://www.w3.org/2001/xml-events" ev:event="load">alert(1)</handler> </svg>', + true, + true, + 'SVG with element handler (http://html5sec.org/#94)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <feImage> <set attributeName="xlink:href" to="data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ%2BYWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg%3D%3D"/> </feImage> </svg>', + true, + true, + 'SVG with href to data: url (http://html5sec.org/#95)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" id="foo"> <x xmlns="http://www.w3.org/2001/xml-events" event="load" observer="foo" handler="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Chandler%20xml%3Aid%3D%22bar%22%20type%3D%22application%2Fecmascript%22%3E alert(1) %3C%2Fhandler%3E%0A%3C%2Fsvg%3E%0A#bar"/> </svg>', + true, + true, + 'SVG with Tiny handler (http://html5sec.org/#104)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect fill="white" style="clip-path:url(test3.svg#a);fill:url(#b);filter:url(#c);marker:url(#d);mask:url(#e);stroke:url(#f);"/> </svg>', + true, + true, + 'SVG with new CSS styles properties (http://html5sec.org/#109)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"><rect fill="white" width="1000" height="1000"/></a> <rect clip-path="url(test3.svg#a)" /> </svg>', + true, + true, + 'SVG with new CSS styles properties as attributes' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <a id="x"> <rect fill="white" width="1000" height="1000"/> </a> <rect fill="url(http://html5sec.org/test3.svg#a)" /> </svg>', + true, + true, + 'SVG with new CSS styles properties as attributes (2)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <path d="M0,0" style="marker-start:url(test4.svg#a)"/> </svg>', + true, + true, + 'SVG with path marker-start (http://html5sec.org/#110)' + ), + array( + '<?xml version="1.0"?> <?xml-stylesheet type="text/xml" href="#stylesheet"?> <!DOCTYPE doc [ <!ATTLIST xsl:stylesheet id ID #REQUIRED>]> <svg xmlns="http://www.w3.org/2000/svg"> <xsl:stylesheet id="stylesheet" version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <iframe xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(1)"></iframe> </xsl:template> </xsl:stylesheet> <circle fill="red" r="40"></circle> </svg>', + true, + true, + 'SVG with embedded stylesheet (http://html5sec.org/#125)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" id="x"> <listener event="load" handler="#y" xmlns="http://www.w3.org/2001/xml-events" observer="x"/> <handler id="y">alert(1)</handler> </svg>', + true, + true, + 'SVG with handler attribute (http://html5sec.org/#127)' + ), + array( + // Haven't found a browser that accepts this particular example, but we + // don't want to allow embeded svgs, ever + '<svg> <image style=\'filter:url("data:image/svg+xml;charset=utf-8;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzY3JpcHQ/YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4NCg==")\' /> </svg>', + true, + true, + 'SVG with image filter via style (http://html5sec.org/#129)' + ), + array( + // This doesn't seem possible without embedding the svg, but just in case + '<svg> <a xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="?"> <circle r="400"></circle> <animate attributeName="xlink:href" begin="0" from="javascript:alert(1)" to="" /> </a></svg>', + true, + true, + 'SVG with animate from (http://html5sec.org/#137)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a><text y="1em">Click me</text> <animate attributeName="xlink:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a></svg>', + true, + true, + 'SVG with animate xlink:href (http://html5sec.org/#137)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:y="http://www.w3.org/1999/xlink"> <a y:href="#"> <text y="1em">Click me</text> <animate attributeName="y:href" values="javascript:alert(\'Bang!\')" begin="0s" dur="0.1s" fill="freeze" /> </a> </svg>', + true, + true, + 'SVG with animate y:href (http://html5sec.org/#137)' + ), + + // Other hostile SVG's + array( + '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns:xlink="http://www.w3.org/1999/xlink"> <image xlink:href="https://upload.wikimedia.org/wikipedia/commons/3/34/Bahnstrecke_Zeitz-Camburg_1930.png" /> </svg>', + true, + true, + 'SVG with non-local image href (bug 65839)' + ), + array( + '<?xml version="1.0" ?> <?xml-stylesheet type="text/xsl" href="/w/index.php?title=User:Jeeves/test.xsl&action=raw&format=xml" ?> <svg> <height>50</height> <width>100</width> </svg>', + true, + true, + 'SVG with remote stylesheet (bug 57550)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewbox="-1 -1 15 15"> <rect y="0" height="13" width="12" stroke="#179" rx="1" fill="#2ac"/> <text x="1.5" y="11" font-family="courier" stroke="white" font-size="16"><![CDATA[B]]></text> <iframe xmlns="http://www.w3.org/1999/xhtml" srcdoc="<script>alert('XSSED => Domain('+top.document.domain+')');</script>"></iframe> </svg>', + true, + true, + 'SVG with rembeded iframe (bug 60771)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org");</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with @import in style element (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@import url("https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org");<foo/></style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with @import in style element and child element (bug 69008#c11)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="6 3 177 153" xmlns:xlink="http://www.w3.org/1999/xlink"> <style>@imporT "https://fonts.googleapis.com/css?family=Bitter:700&text=WebPlatform.org";</style> <g transform="translate(-.5,-.5)"> <text fill="#474747" x="95" y="150" text-anchor="middle" font-family="Bitter" font-size="20" font-weight="bold">WebPlatform.org</text> </g> </svg>', + true, + true, + 'SVG with case-insensitive @import in style element (bug T85349)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:url(https://www.google.com/images/srpr/logo11w.png)"/> </svg>', + true, + true, + 'SVG with remote background image (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:\55rl(https://www.google.com/images/srpr/logo11w.png)"/> </svg>', + true, + true, + 'SVG with remote background image, encoded (bug 69008)' + ), + array( + '<svg xmlns="http://www.w3.org/2000/svg"> <style> #a { background-image:\55rl(\'https://www.google.com/images/srpr/logo11w.png\'); } </style> <rect width="100" height="100" id="a"/> </svg>', + true, + true, + 'SVG with remote background image, in style element (bug 69008)' + ), + array( + // This currently doesn't seem to work in any browsers, but in case + // http://www.w3.org/TR/css3-images/ is implemented for SVG files + '<svg xmlns="http://www.w3.org/2000/svg"> <rect width="100" height="100" style="background-image:image(\'sprites.svg#xywh=40,0,20,20\')"/> </svg>', + true, + true, + 'SVG with remote background image using image() (bug 69008)' + ), + array( + // As reported by Cure53 + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <a xlink:href="data:text/html;charset=utf-8;base64, PHNjcmlwdD5hbGVydChkb2N1bWVudC5kb21haW4pPC9zY3JpcHQ%2BDQo%3D"> <circle r="400" fill="red"></circle> </a> </svg>', + true, + true, + 'SVG with data:text/html link target (firefox only)' + ), + array( + '<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY lol "lol"> <!ENTITY lol2 "<script>alert('XSSED => '+document.domain);</script>"> ]> <svg xmlns="http://www.w3.org/2000/svg" width="68" height="68" viewBox="-34 -34 68 68" version="1.1"> <circle cx="0" cy="0" r="24" fill="#c8c8c8"/> <text x="0" y="0" fill="black">&lol2;</text> </svg>', + true, + true, + 'SVG with encoded script tag in internal entity (reported by Beyond Security)' + ), + array( + '<?xml version="1.0"?> <!DOCTYPE svg [ <!ENTITY foo SYSTEM "file:///etc/passwd"> ]> <svg xmlns="http://www.w3.org/2000/svg" version="1.1"> <desc>&foo;</desc> <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:1;stroke:rgb(0,0,2)" /> </svg>', + false, + false, + 'SVG with external entity' + ), + + // Test good, but strange files that we want to allow + array( + '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <g> <a xlink:href="http://en.wikipedia.org/wiki/Main_Page"> <path transform="translate(0,496)" id="path6706" d="m 112.09375,107.6875 -5.0625,3.625 -4.3125,5.03125 -0.46875,0.5 -4.09375,3.34375 -9.125,5.28125 -8.625,-3.375 z" style="fill:#cccccc;fill-opacity:1;stroke:#6e6e6e;stroke-width:0.69999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;display:inline" /> </a> </g> </svg>', + true, + false, + 'SVG with <a> link to a remote site' + ), + array( + '<svg> <defs> <filter id="filter6226" x="-0.93243687" width="2.8648737" y="-0.24250539" height="1.4850108"> <feGaussianBlur stdDeviation="3.2344681" id="feGaussianBlur6228" /> </filter> <clipPath id="clipPath2436"> <path d="M 0,0 L 0,0 L 0,0 L 0,0 z" id="path2438" /> </clipPath> </defs> <g clip-path="url(#clipPath2436)" id="g2460"> <text id="text2466"> <tspan>12345</tspan> </text> </g> <path style="fill:#346733;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:1, 1;stroke-dashoffset:0;filter:url(\'#filter6226\');fill-opacity:1;opacity:0.79807692" d="M 236.82371,332.63732 C 236.92217,332.63732 z" id="path5618" /> </svg>', + true, + false, + 'SVG with local urls, including filter: in style' + ), + ); + } +} + +class UploadTestHandler extends UploadBase { + public function initializeFromRequest( &$request ) { + } + + public function testTitleValidation( $name ) { + $this->mTitle = false; + $this->mDesiredDestName = $name; + $this->mTitleError = UploadBase::OK; + $this->getTitle(); + + return $this->mTitleError; + } + + /** + * Almost the same as UploadBase::detectScriptInSvg, except it's + * public, works on an xml string instead of filename, and returns + * the result instead of interpreting them. + */ + public function checkSvgString( $svg ) { + $check = new XmlTypeCheck( + $svg, + array( $this, 'checkSvgScriptCallback' ), + false, + array( 'processing_instruction_handler' => 'UploadBase::checkSvgPICallback' ) + ); + return array( $check->wellFormed, $check->filterMatch ); + } +} diff --git a/tests/phpunit/includes/upload/UploadFromUrlTest.php b/tests/phpunit/includes/upload/UploadFromUrlTest.php new file mode 100644 index 00000000..ec56b63e --- /dev/null +++ b/tests/phpunit/includes/upload/UploadFromUrlTest.php @@ -0,0 +1,328 @@ +<?php + +/** + * @group Broken + * @group Upload + * @group Database + * + * @covers UploadFromUrl + */ +class UploadFromUrlTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( array( + 'wgEnableUploads' => true, + 'wgAllowCopyUploads' => true, + 'wgAllowAsyncCopyUploads' => true, + ) ); + wfSetupSession(); + + if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) { + $this->deleteFile( 'UploadFromUrlTest.png' ); + } + } + + protected function doApiRequest( array $params, array $unused = null, + $appendModule = false, User $user = null + ) { + $sessionId = session_id(); + session_write_close(); + + $req = new FauxRequest( $params, true, $_SESSION ); + $module = new ApiMain( $req, true ); + $module->execute(); + + wfSetupSession( $sessionId ); + + return array( $module->getResultData(), $req ); + } + + /** + * Ensure that the job queue is empty before continuing + */ + public function testClearQueue() { + $job = JobQueueGroup::singleton()->pop(); + while ( $job ) { + $job = JobQueueGroup::singleton()->pop(); + } + $this->assertFalse( $job ); + } + + /** + * @depends testClearQueue + */ + public function testSetupUrlDownload( $data ) { + $token = $this->user->getEditToken(); + $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" ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "One of the parameters sessionkey, file, url, statuskey is required", + $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://www.example.com/test.png', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "The filename parameter must be set", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $this->user->removeGroup( 'sysop' ); + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://www.example.com/test.png', + 'filename' => 'UploadFromUrlTest.png', + 'token' => $token, + ), $data ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( "Permission denied", $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + + $this->user->addGroup( 'sysop' ); + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'filename' => 'UploadFromUrlTest.png', + 'token' => $token, + ), $data ); + + $this->assertEquals( $data[0]['upload']['result'], 'Queued', 'Queued upload' ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertThat( $job, $this->isInstanceOf( 'UploadFromUrlJob' ), 'Queued upload inserted' ); + } + + /** + * @depends testClearQueue + */ + public function testAsyncUpload( $data ) { + $token = $this->user->getEditToken(); + + $this->user->addGroup( 'users' ); + + $data = $this->doAsyncUpload( $token, true ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + /** + * @depends testClearQueue + */ + public function testAsyncUploadWarning( $data ) { + $token = $this->user->getEditToken(); + + $this->user->addGroup( 'users' ); + + $data = $this->doAsyncUpload( $token ); + + $this->assertEquals( $data[0]['upload']['result'], 'Warning' ); + $this->assertTrue( isset( $data[0]['upload']['sessionkey'] ) ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'sessionkey' => $data[0]['upload']['sessionkey'], + 'filename' => 'UploadFromUrlTest.png', + 'ignorewarnings' => 1, + 'token' => $token, + ) ); + $this->assertEquals( $data[0]['upload']['result'], 'Success' ); + $this->assertEquals( $data[0]['upload']['filename'], 'UploadFromUrlTest.png' ); + $this->assertTrue( wfLocalFile( $data[0]['upload']['filename'] )->exists() ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + /** + * @depends testClearQueue + */ + public function testSyncDownload( $data ) { + $token = $this->user->getEditToken(); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job, 'Starting with an empty jobqueue' ); + + $this->user->addGroup( 'users' ); + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'ignorewarnings' => true, + 'token' => $token, + ), $data ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job ); + + $this->assertEquals( 'Success', $data[0]['upload']['result'] ); + $this->deleteFile( 'UploadFromUrlTest.png' ); + + return $data; + } + + public function testLeaveMessage() { + $token = $this->user->user->getEditToken(); + + $talk = $this->user->user->getTalkPage(); + if ( $talk->exists() ) { + $page = WikiPage::factory( $talk ); + $page->doDeleteArticle( '' ); + } + + $this->assertFalse( + (bool)$talk->getArticleID( Title::GAID_FOR_UPDATE ), + 'User talk does not exist' + ); + + $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + 'ignorewarnings' => 1, + ) ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + $job->run(); + + $this->assertTrue( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + $this->assertTrue( (bool)$talk->getArticleID( Title::GAID_FOR_UPDATE ), 'User talk exists' ); + + $this->deleteFile( 'UploadFromUrlTest.png' ); + + $exception = false; + try { + $this->doApiRequest( array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + 'leavemessage' => 1, + ) ); + } catch ( UsageException $e ) { + $exception = true; + $this->assertEquals( + 'Using leavemessage without ignorewarnings is not supported', + $e->getMessage() + ); + } + $this->assertTrue( $exception ); + + $job = JobQueueGroup::singleton()->pop(); + $this->assertFalse( $job ); + + return; + /* + // Broken until using leavemessage with ignorewarnings is supported + $talkRev = Revision::newFromTitle( $talk ); + $talkSize = $talkRev->getSize(); + + $job->run(); + + $this->assertFalse( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ); + + $talkRev = Revision::newFromTitle( $talk ); + $this->assertTrue( $talkRev->getSize() > $talkSize, 'New message left' ); + */ + } + + /** + * Helper function to perform an async upload, execute the job and fetch + * the status + * + * @param string $token + * @param bool $ignoreWarnings + * @param bool $leaveMessage + * @return array The result of action=upload&statuskey=key + */ + private function doAsyncUpload( $token, $ignoreWarnings = false, $leaveMessage = false ) { + $params = array( + 'action' => 'upload', + 'filename' => 'UploadFromUrlTest.png', + 'url' => 'http://upload.wikimedia.org/wikipedia/mediawiki/b/bc/Wiki.png', + 'asyncdownload' => 1, + 'token' => $token, + ); + if ( $ignoreWarnings ) { + $params['ignorewarnings'] = 1; + } + if ( $leaveMessage ) { + $params['leavemessage'] = 1; + } + + $data = $this->doApiRequest( $params ); + $this->assertEquals( $data[0]['upload']['result'], 'Queued' ); + $this->assertTrue( isset( $data[0]['upload']['statuskey'] ) ); + $statusKey = $data[0]['upload']['statuskey']; + + $job = JobQueueGroup::singleton()->pop(); + $this->assertEquals( 'UploadFromUrlJob', get_class( $job ) ); + + $status = $job->run(); + $this->assertTrue( $status ); + + $data = $this->doApiRequest( array( + 'action' => 'upload', + 'statuskey' => $statusKey, + 'token' => $token, + ) ); + + return $data; + } + + protected function deleteFile( $name ) { + $t = Title::newFromText( $name, NS_FILE ); + $this->assertTrue( $t->exists(), "File '$name' exists" ); + + if ( $t->exists() ) { + $file = wfFindFile( $name, array( 'ignoreRedirect' => true ) ); + $empty = ""; + FileDeleteForm::doDelete( $t, $file, $empty, "none", true ); + $page = WikiPage::factory( $t ); + $page->doDeleteArticle( "testing" ); + } + $t = Title::newFromText( $name, NS_FILE ); + + $this->assertFalse( $t->exists(), "File '$name' was deleted" ); + } +} diff --git a/tests/phpunit/includes/upload/UploadStashTest.php b/tests/phpunit/includes/upload/UploadStashTest.php new file mode 100644 index 00000000..d5d1188e --- /dev/null +++ b/tests/phpunit/includes/upload/UploadStashTest.php @@ -0,0 +1,107 @@ +<?php + +/** + * @group Database + * + * @covers UploadStash + */ +class UploadStashTest extends MediaWikiTestCase { + /** + * @var array Array of UploadStashTestUser + */ + public static $users; + + /** + * @var string + */ + private $bug29408File; + + protected function setUp() { + parent::setUp(); + + // Setup a file for bug 29408 + $this->bug29408File = __DIR__ . '/bug29408'; + file_put_contents( $this->bug29408File, "\x00" ); + + self::$users = array( + 'sysop' => new TestUser( + 'Uploadstashtestsysop', + 'Upload Stash Test Sysop', + 'upload_stash_test_sysop@example.com', + array( 'sysop' ) + ), + 'uploader' => new TestUser( + 'Uploadstashtestuser', + 'Upload Stash Test User', + 'upload_stash_test_user@example.com', + array() + ) + ); + } + + protected function tearDown() { + if ( file_exists( $this->bug29408File . "." ) ) { + unlink( $this->bug29408File . "." ); + } + + if ( file_exists( $this->bug29408File ) ) { + unlink( $this->bug29408File ); + } + + parent::tearDown(); + } + + /** + * @todo give this test a real name explaining what is being tested here + */ + public function testBug29408() { + $this->setMwGlobals( 'wgUser', self::$users['uploader']->user ); + + $repo = RepoGroup::singleton()->getLocalRepo(); + $stash = new UploadStash( $repo ); + + // Throws exception caught by PHPUnit on failure + $file = $stash->stashFile( $this->bug29408File ); + // We'll never reach this point if we hit bug 29408 + $this->assertTrue( true, 'Unrecognized file without extension' ); + + $stash->removeFile( $file->getFileKey() ); + } + + public static function provideInvalidRequests() { + return array( + 'Check failure on bad wpFileKey' => + array( new FauxRequest( array( 'wpFileKey' => 'foo' ) ) ), + 'Check failure on bad wpSessionKey' => + array( new FauxRequest( array( 'wpSessionKey' => 'foo' ) ) ), + ); + } + + /** + * @dataProvider provideInvalidRequests + */ + public function testValidRequestWithInvalidRequests( $request ) { + $this->assertFalse( UploadFromStash::isValidRequest( $request ) ); + } + + public static function provideValidRequests() { + return array( + 'Check good wpFileKey' => + array( new FauxRequest( array( 'wpFileKey' => 'testkey-test.test' ) ) ), + 'Check good wpSessionKey' => + array( new FauxRequest( array( 'wpFileKey' => 'testkey-test.test' ) ) ), + 'Check key precedence' => + array( new FauxRequest( array( + 'wpFileKey' => 'testkey-test.test', + 'wpSessionKey' => 'foo' + ) ) ), + ); + } + /** + * @dataProvider provideValidRequests + */ + public function testValidRequestWithValidRequests( $request ) { + $this->assertTrue( UploadFromStash::isValidRequest( $request ) ); + } + +} diff --git a/tests/phpunit/includes/utils/CdbTest.php b/tests/phpunit/includes/utils/CdbTest.php new file mode 100644 index 00000000..487ee1fc --- /dev/null +++ b/tests/phpunit/includes/utils/CdbTest.php @@ -0,0 +1,90 @@ +<?php + +/** + * Test the CDB reader/writer + * @covers CdbWriterPHP + * @covers CdbWriterDBA + */ +class CdbTest extends MediaWikiTestCase { + + protected function setUp() { + parent::setUp(); + if ( !CdbReader::haveExtension() ) { + $this->markTestSkipped( 'Native CDB support is not available' ); + } + } + + /** + * @group medium + */ + public function testCdb() { + $dir = wfTempDir(); + if ( !is_writable( $dir ) ) { + $this->markTestSkipped( "Temp dir isn't writable" ); + } + + $phpcdbfile = $this->getNewTempFile(); + $dbacdbfile = $this->getNewTempFile(); + + $w1 = new CdbWriterPHP( $phpcdbfile ); + $w2 = new CdbWriterDBA( $dbacdbfile ); + + $data = array(); + for ( $i = 0; $i < 1000; $i++ ) { + $key = $this->randomString(); + $value = $this->randomString(); + $w1->set( $key, $value ); + $w2->set( $key, $value ); + + if ( !isset( $data[$key] ) ) { + $data[$key] = $value; + } + } + + $w1->close(); + $w2->close(); + + $this->assertEquals( + md5_file( $phpcdbfile ), + md5_file( $dbacdbfile ), + 'same hash' + ); + + $r1 = new CdbReaderPHP( $phpcdbfile ); + $r2 = new CdbReaderDBA( $dbacdbfile ); + + foreach ( $data as $key => $value ) { + if ( $key === '' ) { + // Known bug + continue; + } + $v1 = $r1->get( $key ); + $v2 = $r2->get( $key ); + + $v1 = $v1 === false ? '(not found)' : $v1; + $v2 = $v2 === false ? '(not found)' : $v2; + + # cdbAssert( 'Mismatch', $key, $v1, $v2 ); + $this->cdbAssert( "PHP error", $key, $v1, $value ); + $this->cdbAssert( "DBA error", $key, $v2, $value ); + } + } + + private function randomString() { + $len = mt_rand( 0, 10 ); + $s = ''; + for ( $j = 0; $j < $len; $j++ ) { + $s .= chr( mt_rand( 0, 255 ) ); + } + + return $s; + } + + private function cdbAssert( $msg, $key, $v1, $v2 ) { + $this->assertEquals( + $v2, + $v1, + $msg . ', k=' . bin2hex( $key ) + ); + } +} diff --git a/tests/phpunit/includes/utils/IPTest.php b/tests/phpunit/includes/utils/IPTest.php new file mode 100644 index 00000000..ebe347fd --- /dev/null +++ b/tests/phpunit/includes/utils/IPTest.php @@ -0,0 +1,580 @@ +<?php +/** + * Tests for IP validity functions. + * + * Ported from /t/inc/IP.t by avar. + * + * @group IP + * @todo Test methods in this call should be split into a method and a + * dataprovider. + */ + +class IPTest extends MediaWikiTestCase { + /** + * not sure it should be tested with boolean false. hashar 20100924 + * @covers IP::isIPAddress + */ + public function testisIPAddress() { + $this->assertFalse( IP::isIPAddress( false ), 'Boolean false is not an IP' ); + $this->assertFalse( IP::isIPAddress( true ), 'Boolean true is not an IP' ); + $this->assertFalse( IP::isIPAddress( "" ), 'Empty string is not an IP' ); + $this->assertFalse( IP::isIPAddress( 'abc' ), 'Garbage IP string' ); + $this->assertFalse( IP::isIPAddress( ':' ), 'Single ":" is not an IP' ); + $this->assertFalse( IP::isIPAddress( '2001:0DB8::A:1::1' ), 'IPv6 with a double :: occurrence' ); + $this->assertFalse( + IP::isIPAddress( '2001:0DB8::A:1::' ), + 'IPv6 with a double :: occurrence, last at end' + ); + $this->assertFalse( + IP::isIPAddress( '::2001:0DB8::5:1' ), + 'IPv6 with a double :: occurrence, firt at beginning' + ); + $this->assertFalse( IP::isIPAddress( '124.24.52' ), 'IPv4 not enough quads' ); + $this->assertFalse( IP::isIPAddress( '24.324.52.13' ), 'IPv4 out of range' ); + $this->assertFalse( IP::isIPAddress( '.24.52.13' ), 'IPv4 starts with period' ); + $this->assertFalse( IP::isIPAddress( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' ); + $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' ); + $this->assertTrue( IP::isIPAddress( '74.24.52.13/20', 'IPv4 range' ) ); + $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' ); + $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' ); + + $validIPs = array( 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac', + '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ); + foreach ( $validIPs as $ip ) { + $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" ); + } + } + + /** + * @covers IP::isIPv6 + */ + public function testisIPv6() { + $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' ); + $this->assertFalse( + IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + + $this->assertFalse( IP::isIPv6( ':::' ) ); + $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' ); + + $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' ); + $this->assertTrue( IP::isIPv6( '::0' ) ); + $this->assertTrue( IP::isIPv6( '::fc' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) ); + + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' ); + + $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d', 'IPv6 with "::" and 4 words' ) ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) ); + } + + /** + * @covers IP::isIPv4 + */ + public function testisIPv4() { + $this->assertFalse( IP::isIPv4( false ), 'Boolean false is not an IP' ); + $this->assertFalse( IP::isIPv4( true ), 'Boolean true is not an IP' ); + $this->assertFalse( IP::isIPv4( "" ), 'Empty string is not an IP' ); + $this->assertFalse( IP::isIPv4( 'abc' ) ); + $this->assertFalse( IP::isIPv4( ':' ) ); + $this->assertFalse( IP::isIPv4( '124.24.52' ), 'IPv4 not enough quads' ); + $this->assertFalse( IP::isIPv4( '24.324.52.13' ), 'IPv4 out of range' ); + $this->assertFalse( IP::isIPv4( '.24.52.13' ), 'IPv4 starts with period' ); + + $this->assertTrue( IP::isIPv4( '124.24.52.13' ) ); + $this->assertTrue( IP::isIPv4( '1.24.52.13' ) ); + $this->assertTrue( IP::isIPv4( '74.24.52.13/20', 'IPv4 range' ) ); + } + + /** + * @covers IP::isValid + */ + public function testValidIPs() { + foreach ( range( 0, 255 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" ); + } + } + foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) { + $a = sprintf( "%04x", $i ); + $b = sprintf( "%03x", $i ); + $c = sprintf( "%02x", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" ); + } + } + // test with some abbreviations + $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isValid( 'fc:100::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0::' ), + 'IPv6 with 8 words ending with "::"' + ); + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + } + + /** + * @covers IP::isValid + */ + public function testInvalidIPs() { + // Out of range... + foreach ( range( 256, 999 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" ); + } + } + foreach ( range( 'g', 'z' ) as $i ) { + $a = sprintf( "%04s", $i ); + $b = sprintf( "%03s", $i ); + $c = sprintf( "%02s", $i ); + foreach ( array_unique( array( $a, $b, $c ) ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" ); + } + } + // Have CIDR + $ipCIDRs = array( + '212.35.31.121/32', + '212.35.31.121/18', + '212.35.31.121/24', + '::ff:d:321:5/96', + 'ff::d3:321:5/116', + 'c:ff:12:1:ea:d:321:5/120', + ); + foreach ( $ipCIDRs as $i ) { + $this->assertFalse( IP::isValid( $i ), + "$i is an invalid IP address because it is a block" ); + } + // Incomplete/garbage + $invalid = array( + 'www.xn--var-xla.net', + '216.17.184.G', + '216.17.184.1.', + '216.17.184', + '216.17.184.', + '256.17.184.1' + ); + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" ); + } + } + + /** + * @covers IP::isValidBlock + */ + public function testValidBlocks() { + $valid = array( + '116.17.184.5/32', + '0.17.184.5/30', + '16.17.184.1/24', + '30.242.52.14/1', + '10.232.52.13/8', + '30.242.52.14/0', + '::e:f:2001/96', + '::c:f:2001/128', + '::10:f:2001/70', + '::fe:f:2001/1', + '::6d:f:2001/8', + '::fe:f:2001/0', + ); + foreach ( $valid as $i ) { + $this->assertTrue( IP::isValidBlock( $i ), "$i is a valid IP block" ); + } + } + + /** + * @covers IP::isValidBlock + */ + public function testInvalidBlocks() { + $invalid = array( + '116.17.184.5/33', + '0.17.184.5/130', + '16.17.184.1/-1', + '10.232.52.13/*', + '7.232.52.13/ab', + '11.232.52.13/', + '::e:f:2001/129', + '::c:f:2001/228', + '::10:f:2001/-1', + '::6d:f:2001/*', + '::86:f:2001/ab', + '::23:f:2001/', + ); + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValidBlock( $i ), "$i is not a valid IP block" ); + } + } + + /** + * Improve IP::sanitizeIP() code coverage + * @todo Most probably incomplete + */ + public function testSanitizeIP() { + $this->assertNull( IP::sanitizeIP( '' ) ); + $this->assertNull( IP::sanitizeIP( ' ' ) ); + } + + /** + * @covers IP::toHex + * @dataProvider provideToHex + */ + public function testToHex( $expected, $input ) { + $result = IP::toHex( $input ); + $this->assertTrue( $result === false || is_string( $result ) ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testToHex() + */ + public static function provideToHex() { + return array( + array( '00000001', '0.0.0.1' ), + array( '01020304', '1.2.3.4' ), + array( '7F000001', '127.0.0.1' ), + array( '80000000', '128.0.0.0' ), + array( 'DEADCAFE', '222.173.202.254' ), + array( 'FFFFFFFF', '255.255.255.255' ), + array( false, 'IN.VA.LI.D' ), + array( 'v6-00000000000000000000000000000001', '::1' ), + array( 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ), + array( 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ), + array( false, 'IN:VA::LI:D' ), + array( false, ':::1' ) + ); + } + + /** + * @covers IP::isPublic + */ + public function testPrivateIPs() { + $private = array( 'fc00::3', 'fc00::ff', '::1', '10.0.0.1', '172.16.0.1', '192.168.0.1' ); + foreach ( $private as $p ) { + $this->assertFalse( IP::isPublic( $p ), "$p is not a public IP address" ); + } + $public = array( '2001:5c0:1000:a::133', 'fc::3', '00FC::' ); + foreach ( $public as $p ) { + $this->assertTrue( IP::isPublic( $p ), "$p is a public IP address" ); + } + } + + // Private wrapper used to test CIDR Parsing. + private function assertFalseCIDR( $CIDR, $msg = '' ) { + $ff = array( false, false ); + $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg ); + } + + // Private wrapper to test network shifting using only dot notation + private function assertNet( $expected, $CIDR ) { + $parse = IP::parseCIDR( $CIDR ); + $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" ); + } + + /** + * @covers IP::hexToQuad + */ + public function testHexToQuad() { + $this->assertEquals( '0.0.0.1', IP::hexToQuad( '00000001' ) ); + $this->assertEquals( '255.0.0.0', IP::hexToQuad( 'FF000000' ) ); + $this->assertEquals( '255.255.255.255', IP::hexToQuad( 'FFFFFFFF' ) ); + $this->assertEquals( '10.188.222.255', IP::hexToQuad( '0ABCDEFF' ) ); + // hex not left-padded... + $this->assertEquals( '0.0.0.0', IP::hexToQuad( '0' ) ); + $this->assertEquals( '0.0.0.1', IP::hexToQuad( '1' ) ); + $this->assertEquals( '0.0.0.255', IP::hexToQuad( 'FF' ) ); + $this->assertEquals( '0.0.255.0', IP::hexToQuad( 'FF00' ) ); + } + + /** + * @covers IP::hexToOctet + */ + public function testHexToOctet() { + $this->assertEquals( '0:0:0:0:0:0:0:1', + IP::hexToOctet( '00000000000000000000000000000001' ) ); + $this->assertEquals( '0:0:0:0:0:0:FF:3', + IP::hexToOctet( '00000000000000000000000000FF0003' ) ); + $this->assertEquals( '0:0:0:0:0:0:FF00:6', + IP::hexToOctet( '000000000000000000000000FF000006' ) ); + $this->assertEquals( '0:0:0:0:0:0:FCCF:FAFF', + IP::hexToOctet( '000000000000000000000000FCCFFAFF' ) ); + $this->assertEquals( 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + IP::hexToOctet( 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ) ); + // hex not left-padded... + $this->assertEquals( '0:0:0:0:0:0:0:0', IP::hexToOctet( '0' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:1', IP::hexToOctet( '1' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:FF', IP::hexToOctet( 'FF' ) ); + $this->assertEquals( '0:0:0:0:0:0:0:FFD0', IP::hexToOctet( 'FFD0' ) ); + $this->assertEquals( '0:0:0:0:0:0:FA00:0', IP::hexToOctet( 'FA000000' ) ); + $this->assertEquals( '0:0:0:0:0:0:FCCF:FAFF', IP::hexToOctet( 'FCCFFAFF' ) ); + } + + /** + * IP::parseCIDR() returns an array containing a signed IP address + * representing the network mask and the bit mask. + * @covers IP::parseCIDR + */ + public function testCIDRParsing() { + $this->assertFalseCIDR( '192.0.2.0', "missing mask" ); + $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" ); + + // Verify if statement + $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" ); + $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" ); + $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" ); + $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); + + // Check internal logic + # 0 mask always result in array(0,0) + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '192.0.0.2/0' ) ); + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '0.0.0.0/0' ) ); + $this->assertEquals( array( 0, 0 ), IP::parseCIDR( '255.255.255.255/0' ) ); + + // @todo FIXME: Add more tests. + + # This part test network shifting + $this->assertNet( '192.0.0.0', '192.0.0.2/24' ); + $this->assertNet( '192.168.5.0', '192.168.5.13/24' ); + $this->assertNet( '10.0.0.160', '10.0.0.161/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/30' ); + $this->assertNet( '10.0.0.4', '10.0.0.4/30' ); + $this->assertNet( '172.17.32.0', '172.17.35.48/21' ); + $this->assertNet( '10.128.0.0', '10.135.0.0/9' ); + $this->assertNet( '134.0.0.0', '134.0.5.1/8' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeOnValidIp() { + $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ), + 'Canonicalization of a valid IP returns it unchanged' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeMappedAddress() { + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::ffff:192.0.2.152' ) + ); + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::192.0.2.152' ) + ); + } + + /** + * Issues there are most probably from IP::toHex() or IP::parseRange() + * @covers IP::isInRange + * @dataProvider provideIPsAndRanges + */ + public function testIPIsInRange( $expected, $addr, $range, $message = '' ) { + $this->assertEquals( + $expected, + IP::isInRange( $addr, $range ), + $message + ); + } + + /** Provider for testIPIsInRange() */ + public static function provideIPsAndRanges() { + # Format: (expected boolean, address, range, optional message) + return array( + # IPv4 + array( true, '192.0.2.0', '192.0.2.0/24', 'Network address' ), + array( true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ), + array( true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ), + + array( false, '0.0.0.0', '192.0.2.0/24' ), + array( false, '255.255.255', '192.0.2.0/24' ), + + # IPv6 + array( false, '::1', '2001:DB8::/32' ), + array( false, '::', '2001:DB8::/32' ), + array( false, 'FE80::1', '2001:DB8::/32' ), + + array( true, '2001:DB8::', '2001:DB8::/32' ), + array( true, '2001:0DB8::', '2001:DB8::/32' ), + array( true, '2001:DB8::1', '2001:DB8::/32' ), + array( true, '2001:0DB8::1', '2001:DB8::/32' ), + array( true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + '2001:DB8::/32' ), + + array( false, '2001:0DB8:F::', '2001:DB8::/96' ), + ); + } + + /** + * Test for IP::splitHostAndPort(). + * @dataProvider provideSplitHostAndPort + */ + public function testSplitHostAndPort( $expected, $input, $description ) { + $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description ); + } + + /** + * Provider for IP::splitHostAndPort() + */ + public static function provideSplitHostAndPort() { + return array( + array( false, '[', 'Unclosed square bracket' ), + array( false, '[::', 'Unclosed square bracket 2' ), + array( array( '::', false ), '::', 'Bare IPv6 0' ), + array( array( '::1', false ), '::1', 'Bare IPv6 1' ), + array( array( '::', false ), '[::]', 'Bracketed IPv6 0' ), + array( array( '::1', false ), '[::1]', 'Bracketed IPv6 1' ), + array( array( '::1', 80 ), '[::1]:80', 'Bracketed IPv6 with port' ), + array( false, '::x', 'Double colon but no IPv6' ), + array( array( 'x', 80 ), 'x:80', 'Hostname and port' ), + array( false, 'x:x', 'Hostname and invalid port' ), + array( array( 'x', false ), 'x', 'Plain hostname' ) + ); + } + + /** + * Test for IP::combineHostAndPort() + * @dataProvider provideCombineHostAndPort + */ + public function testCombineHostAndPort( $expected, $input, $description ) { + list( $host, $port, $defaultPort ) = $input; + $this->assertEquals( + $expected, + IP::combineHostAndPort( $host, $port, $defaultPort ), + $description ); + } + + /** + * Provider for IP::combineHostAndPort() + */ + public static function provideCombineHostAndPort() { + return array( + array( '[::1]', array( '::1', 2, 2 ), 'IPv6 default port' ), + array( '[::1]:2', array( '::1', 2, 3 ), 'IPv6 non-default port' ), + array( 'x', array( 'x', 2, 2 ), 'Normal default port' ), + array( 'x:2', array( 'x', 2, 3 ), 'Normal non-default port' ), + ); + } + + /** + * Test for IP::sanitizeRange() + * @dataProvider provideIPCIDRs + */ + public function testSanitizeRange( $input, $expected, $description ) { + $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description ); + } + + /** + * Provider for IP::testSanitizeRange() + */ + public static function provideIPCIDRs() { + return array( + array( '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ), + array( '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ), + array( '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ), + array( '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ), + array( '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ), + array( '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ), + array( '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ), + array( '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ), + ); + } + + /** + * Test for IP::prettifyIP() + * @dataProvider provideIPsToPrettify + */ + public function testPrettifyIP( $ip, $prettified ) { + $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" ); + } + + /** + * Provider for IP::testPrettifyIP() + */ + public static function provideIPsToPrettify() { + return array( + array( '0:0:0:0:0:0:0:0', '::' ), + array( '0:0:0::0:0:0', '::' ), + array( '0:0:0:1:0:0:0:0', '0:0:0:1::' ), + array( '0:0::f', '::f' ), + array( '0::0:0:0:33:fef:b', '::33:fef:b' ), + array( '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ), + array( '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ), + array( 'abbc:2004::0:0:0:0', 'abbc:2004::' ), + array( 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ), + array( '0:0:0:0:0:0:0:0/16', '::/16' ), + array( '0:0:0::0:0:0/64', '::/64' ), + array( '0:0::f/52', '::f/52' ), + array( '::0:0:33:fef:b/52', '::33:fef:b/52' ), + array( '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ), + array( '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ), + array( 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ), + array( 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ), + ); + } +} diff --git a/tests/phpunit/includes/utils/MWCryptHKDFTest.php b/tests/phpunit/includes/utils/MWCryptHKDFTest.php new file mode 100644 index 00000000..7e37534a --- /dev/null +++ b/tests/phpunit/includes/utils/MWCryptHKDFTest.php @@ -0,0 +1,89 @@ +<?php +/** + * + * @group HKDF + */ + +class MWCryptHKDFTest extends MediaWikiTestCase { + + /** + * Test basic usage works + */ + public function testGenerate() { + $a = MWCryptHKDF::generateHex( 64 ); + $b = MWCryptHKDF::generateHex( 64 ); + + $this->assertTrue( strlen( $a ) == 64, "MWCryptHKDF produced fewer bytes than expected" ); + $this->assertTrue( strlen( $b ) == 64, "MWCryptHKDF produced fewer bytes than expected" ); + $this->assertFalse( $a == $b, "Two runs of MWCryptHKDF produced the same result." ); + } + + /** + * @dataProvider providerRfc5869 + */ + public function testRfc5869( $hash, $ikm, $salt, $info, $L, $prk, $okm ) { + $ikm = pack( 'H*', $ikm ); + $salt = pack( 'H*', $salt ); + $info = pack( 'H*', $info ); + $okm = pack( 'H*', $okm ); + $result = MWCryptHKDF::HKDF( $hash, $ikm, $salt, $info, $L ); + $this->assertEquals( $okm, $result ); + } + + /** + * Test vectors from Appendix A on http://tools.ietf.org/html/rfc5869 + */ + public static function providerRfc5869() { + + return array( + // A.1 + array( 'sha256', + '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm + '000102030405060708090a0b0c', // salt + 'f0f1f2f3f4f5f6f7f8f9', // context + 42, // bytes + '077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5', // prk + '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865' // okm + ), + // A.2 + array( 'sha256', + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f', + '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf', + 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', + 82, + '06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244', + 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87' + ), + // A.3 + array( 'sha256', + '0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b', // ikm + '', // salt + '', // context + 42, // bytes + '19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04', // prk + '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8' // okm + ), + // A.4 + array( 'sha1', + '0b0b0b0b0b0b0b0b0b0b0b', // ikm + '000102030405060708090a0b0c', // salt + 'f0f1f2f3f4f5f6f7f8f9', // context + 42, // bytes + '9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243', // prk + '085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896' // okm + ), + // A.5 + array( 'sha1', + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f', // ikm + '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf', // salt + 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', // context + 82, // bytes + '8adae09a2a307059478d309b26c4115a224cfaf6', // prk + '0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4' // okm + ), + ); + + } + + +} diff --git a/tests/phpunit/includes/utils/StringUtilsTest.php b/tests/phpunit/includes/utils/StringUtilsTest.php new file mode 100644 index 00000000..0fdb8e15 --- /dev/null +++ b/tests/phpunit/includes/utils/StringUtilsTest.php @@ -0,0 +1,149 @@ +<?php + +class StringUtilsTest extends MediaWikiTestCase { + + /** + * This tests StringUtils::isUtf8 whenever we have the mbstring extension + * loaded. + * + * @covers StringUtils::isUtf8 + * @dataProvider provideStringsForIsUtf8Check + */ + public function testIsUtf8WithMbstring( $expected, $string ) { + if ( !function_exists( 'mb_check_encoding' ) ) { + $this->markTestSkipped( 'Test requires the mbstring PHP extension' ); + } + $this->assertEquals( $expected, + StringUtils::isUtf8( $string ), + 'Testing string "' . $this->escaped( $string ) . '" with mb_check_encoding' + ); + } + + /** + * This tests StringUtils::isUtf8 making sure we use the pure PHP + * implementation used as a fallback when mb_check_encoding() is + * not available. + * + * @covers StringUtils::isUtf8 + * @dataProvider provideStringsForIsUtf8Check + */ + public function testIsUtf8WithPhpFallbackImplementation( $expected, $string ) { + $this->assertEquals( $expected, + StringUtils::isUtf8( $string, /** disable mbstring: */true ), + 'Testing string "' . $this->escaped( $string ) . '" with pure PHP implementation' + ); + } + + /** + * Print high range characters as a hexadecimal + * @param string $string + * @return string + */ + function escaped( $string ) { + $escaped = ''; + $length = strlen( $string ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $string[$i]; + $val = ord( $char ); + if ( $val > 127 ) { + $escaped .= '\x' . dechex( $val ); + } else { + $escaped .= $char; + } + } + + return $escaped; + } + + /** + * See also "UTF-8 decoder capability and stress test" by + * Markus Kuhn: + * http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + */ + public static function provideStringsForIsUtf8Check() { + // Expected return values for StringUtils::isUtf8() + $PASS = true; + $FAIL = false; + + return array( + 'some ASCII' => array( $PASS, 'Some ASCII' ), + 'euro sign' => array( $PASS, "Euro sign €" ), + + 'first possible sequence 1 byte' => array( $PASS, "\x00" ), + 'first possible sequence 2 bytes' => array( $PASS, "\xc2\x80" ), + 'first possible sequence 3 bytes' => array( $PASS, "\xe0\xa0\x80" ), + 'first possible sequence 4 bytes' => array( $PASS, "\xf0\x90\x80\x80" ), + 'first possible sequence 5 bytes' => array( $FAIL, "\xf8\x88\x80\x80\x80" ), + 'first possible sequence 6 bytes' => array( $FAIL, "\xfc\x84\x80\x80\x80\x80" ), + + 'last possible sequence 1 byte' => array( $PASS, "\x7f" ), + 'last possible sequence 2 bytes' => array( $PASS, "\xdf\xbf" ), + 'last possible sequence 3 bytes' => array( $PASS, "\xef\xbf\xbf" ), + 'last possible sequence 4 bytes (U+1FFFFF)' => array( $FAIL, "\xf7\xbf\xbf\xbf" ), + 'last possible sequence 5 bytes' => array( $FAIL, "\xfb\xbf\xbf\xbf\xbf" ), + 'last possible sequence 6 bytes' => array( $FAIL, "\xfd\xbf\xbf\xbf\xbf\xbf" ), + + 'boundary 1' => array( $PASS, "\xed\x9f\xbf" ), + 'boundary 2' => array( $PASS, "\xee\x80\x80" ), + 'boundary 3' => array( $PASS, "\xef\xbf\xbd" ), + 'boundary 4' => array( $PASS, "\xf2\x80\x80\x80" ), + 'boundary 5 (U+FFFFF)' => array( $PASS, "\xf3\xbf\xbf\xbf" ), + 'boundary 6 (U+100000)' => array( $PASS, "\xf4\x80\x80\x80" ), + 'boundary 7 (U+10FFFF)' => array( $PASS, "\xf4\x8f\xbf\xbf" ), + 'boundary 8 (U+110000)' => array( $FAIL, "\xf4\x90\x80\x80" ), + + 'malformed 1' => array( $FAIL, "\x80" ), + 'malformed 2' => array( $FAIL, "\xbf" ), + 'malformed 3' => array( $FAIL, "\x80\xbf" ), + 'malformed 4' => array( $FAIL, "\x80\xbf\x80" ), + 'malformed 5' => array( $FAIL, "\x80\xbf\x80\xbf" ), + 'malformed 6' => array( $FAIL, "\x80\xbf\x80\xbf\x80" ), + 'malformed 7' => array( $FAIL, "\x80\xbf\x80\xbf\x80\xbf" ), + 'malformed 8' => array( $FAIL, "\x80\xbf\x80\xbf\x80\xbf\x80" ), + + 'last byte missing 1' => array( $FAIL, "\xc0" ), + 'last byte missing 2' => array( $FAIL, "\xe0\x80" ), + 'last byte missing 3' => array( $FAIL, "\xf0\x80\x80" ), + 'last byte missing 4' => array( $FAIL, "\xf8\x80\x80\x80" ), + 'last byte missing 5' => array( $FAIL, "\xfc\x80\x80\x80\x80" ), + 'last byte missing 6' => array( $FAIL, "\xdf" ), + 'last byte missing 7' => array( $FAIL, "\xef\xbf" ), + 'last byte missing 8' => array( $FAIL, "\xf7\xbf\xbf" ), + 'last byte missing 9' => array( $FAIL, "\xfb\xbf\xbf\xbf" ), + 'last byte missing 10' => array( $FAIL, "\xfd\xbf\xbf\xbf\xbf" ), + + 'extra continuation byte 1' => array( $FAIL, "e\xaf" ), + 'extra continuation byte 2' => array( $FAIL, "\xc3\x89\xaf" ), + 'extra continuation byte 3' => array( $FAIL, "\xef\xbc\xa5\xaf" ), + 'extra continuation byte 4' => array( $FAIL, "\xf0\x9d\x99\xb4\xaf" ), + + 'impossible bytes 1' => array( $FAIL, "\xfe" ), + 'impossible bytes 2' => array( $FAIL, "\xff" ), + 'impossible bytes 3' => array( $FAIL, "\xfe\xfe\xff\xff" ), + + 'overlong sequences 1' => array( $FAIL, "\xc0\xaf" ), + 'overlong sequences 2' => array( $FAIL, "\xc1\xaf" ), + 'overlong sequences 3' => array( $FAIL, "\xe0\x80\xaf" ), + 'overlong sequences 4' => array( $FAIL, "\xf0\x80\x80\xaf" ), + 'overlong sequences 5' => array( $FAIL, "\xf8\x80\x80\x80\xaf" ), + 'overlong sequences 6' => array( $FAIL, "\xfc\x80\x80\x80\x80\xaf" ), + + 'maximum overlong sequences 1' => array( $FAIL, "\xc1\xbf" ), + 'maximum overlong sequences 2' => array( $FAIL, "\xe0\x9f\xbf" ), + 'maximum overlong sequences 3' => array( $FAIL, "\xf0\x8f\xbf\xbf" ), + 'maximum overlong sequences 4' => array( $FAIL, "\xf8\x87\xbf\xbf" ), + 'maximum overlong sequences 5' => array( $FAIL, "\xfc\x83\xbf\xbf\xbf\xbf" ), + + 'surrogates 1 (U+D799)' => array( $PASS, "\xed\x9f\xbf" ), + 'surrogates 2 (U+E000)' => array( $PASS, "\xee\x80\x80" ), + 'surrogates 3 (U+D800)' => array( $FAIL, "\xed\xa0\x80" ), + 'surrogates 4 (U+DBFF)' => array( $FAIL, "\xed\xaf\xbf" ), + 'surrogates 5 (U+DC00)' => array( $FAIL, "\xed\xb0\x80" ), + 'surrogates 6 (U+DFFF)' => array( $FAIL, "\xed\xbf\xbf" ), + 'surrogates 7 (U+D800 U+DC00)' => array( $FAIL, "\xed\xa0\x80\xed\xb0\x80" ), + + 'noncharacters 1' => array( $PASS, "\xef\xbf\xbe" ), + 'noncharacters 2' => array( $PASS, "\xef\xbf\xbf" ), + ); + } +} diff --git a/tests/phpunit/includes/utils/UIDGeneratorTest.php b/tests/phpunit/includes/utils/UIDGeneratorTest.php new file mode 100644 index 00000000..50fa3849 --- /dev/null +++ b/tests/phpunit/includes/utils/UIDGeneratorTest.php @@ -0,0 +1,129 @@ +<?php + +class UIDGeneratorTest extends MediaWikiTestCase { + + protected function tearDown() { + // Bug: 44850 + UIDGenerator::unitTestTearDown(); + parent::tearDown(); + } + + /** + * @dataProvider provider_testTimestampedUID + * @covers UIDGenerator::newTimestampedUID128 + * @covers UIDGenerator::newTimestampedUID88 + */ + public function testTimestampedUID( $method, $digitlen, $bits, $tbits, $hostbits ) { + $id = call_user_func( array( 'UIDGenerator', $method ) ); + $this->assertEquals( true, ctype_digit( $id ), "UID made of digit characters" ); + $this->assertLessThanOrEqual( $digitlen, strlen( $id ), + "UID has the right number of digits" ); + $this->assertLessThanOrEqual( $bits, strlen( wfBaseConvert( $id, 10, 2 ) ), + "UID has the right number of bits" ); + + $ids = array(); + for ( $i = 0; $i < 300; $i++ ) { + $ids[] = call_user_func( array( 'UIDGenerator', $method ) ); + } + + $lastId = array_shift( $ids ); + + $this->assertArrayEquals( array_unique( $ids ), $ids, "All generated IDs are unique." ); + + foreach ( $ids as $id ) { + $id_bin = wfBaseConvert( $id, 10, 2 ); + $lastId_bin = wfBaseConvert( $lastId, 10, 2 ); + + $this->assertGreaterThanOrEqual( + substr( $id_bin, 0, $tbits ), + substr( $lastId_bin, 0, $tbits ), + "New ID timestamp ($id_bin) >= prior one ($lastId_bin)." ); + + if ( $hostbits ) { + $this->assertEquals( + substr( $id_bin, 0, -$hostbits ), + substr( $lastId_bin, 0, -$hostbits ), + "Host ID of ($id_bin) is same as prior one ($lastId_bin)." ); + } + + $lastId = $id; + } + } + + /** + * array( method, length, bits, hostbits ) + * NOTE: When adding a new method name here please update the covers tags for the tests! + */ + public static function provider_testTimestampedUID() { + return array( + array( 'newTimestampedUID128', 39, 128, 46, 48 ), + array( 'newTimestampedUID128', 39, 128, 46, 48 ), + array( 'newTimestampedUID88', 27, 88, 46, 32 ), + ); + } + + /** + * @covers UIDGenerator::newUUIDv4 + */ + public function testUUIDv4() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newUUIDv4(); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newRawUUIDv4 + */ + public function testRawUUIDv4() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newRawUUIDv4(); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newRawUUIDv4 + */ + public function testRawUUIDv4QuickRand() { + for ( $i = 0; $i < 100; $i++ ) { + $id = UIDGenerator::newRawUUIDv4( UIDGenerator::QUICK_RAND ); + $this->assertEquals( true, + preg_match( '!^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$!', $id ), + "UID $id has the right format" ); + } + } + + /** + * @covers UIDGenerator::newSequentialPerNodeID + */ + public function testNewSequentialID() { + $id1 = UIDGenerator::newSequentialPerNodeID( 'test', 32 ); + $id2 = UIDGenerator::newSequentialPerNodeID( 'test', 32 ); + + $this->assertType( 'float', $id1, "ID returned as float" ); + $this->assertType( 'float', $id2, "ID returned as float" ); + $this->assertGreaterThan( 0, $id1, "ID greater than 1" ); + $this->assertGreaterThan( $id1, $id2, "IDs increasing in value" ); + } + + /** + * @covers UIDGenerator::newSequentialPerNodeIDs + */ + public function testNewSequentialIDs() { + $ids = UIDGenerator::newSequentialPerNodeIDs( 'test', 32, 5 ); + $lastId = null; + foreach ( $ids as $id ) { + $this->assertType( 'float', $id, "ID returned as float" ); + $this->assertGreaterThan( 0, $id, "ID greater than 1" ); + if ( $lastId ) { + $this->assertGreaterThan( $lastId, $id, "IDs increasing in value" ); + } + $lastId = $id; + } + } +} diff --git a/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php b/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php new file mode 100644 index 00000000..34ffb535 --- /dev/null +++ b/tests/phpunit/includes/utils/ZipDirectoryReaderTest.php @@ -0,0 +1,84 @@ +<?php + +/** + * @covers ZipDirectoryReader + * NOTE: this test is more like an integration test than a unit test + */ +class ZipDirectoryReaderTest extends MediaWikiTestCase { + protected $zipDir; + protected $entries; + + protected function setUp() { + parent::setUp(); + $this->zipDir = __DIR__ . '/../../data/zip'; + } + + function zipCallback( $entry ) { + $this->entries[] = $entry; + } + + function readZipAssertError( $file, $error, $assertMessage ) { + $this->entries = array(); + $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", array( $this, 'zipCallback' ) ); + $this->assertTrue( $status->hasMessage( $error ), $assertMessage ); + } + + function readZipAssertSuccess( $file, $assertMessage ) { + $this->entries = array(); + $status = ZipDirectoryReader::read( "{$this->zipDir}/$file", array( $this, 'zipCallback' ) ); + $this->assertTrue( $status->isOK(), $assertMessage ); + } + + public function testEmpty() { + $this->readZipAssertSuccess( 'empty.zip', 'Empty zip' ); + } + + public function testMultiDisk0() { + $this->readZipAssertError( 'split.zip', 'zip-unsupported', + 'Split zip error' ); + } + + public function testNoSignature() { + $this->readZipAssertError( 'nosig.zip', 'zip-wrong-format', + 'No signature should give "wrong format" error' ); + } + + public function testSimple() { + $this->readZipAssertSuccess( 'class.zip', 'Simple ZIP' ); + $this->assertEquals( $this->entries, array( array( + 'name' => 'Class.class', + 'mtime' => '20010115000000', + 'size' => 1, + ) ) ); + } + + public function testBadCentralEntrySignature() { + $this->readZipAssertError( 'wrong-central-entry-sig.zip', 'zip-bad', + 'Bad central entry error' ); + } + + public function testTrailingBytes() { + $this->readZipAssertError( 'trail.zip', 'zip-bad', + 'Trailing bytes error' ); + } + + public function testWrongCDStart() { + $this->readZipAssertError( 'wrong-cd-start-disk.zip', 'zip-unsupported', + 'Wrong CD start disk error' ); + } + + public function testCentralDirectoryGap() { + $this->readZipAssertError( 'cd-gap.zip', 'zip-bad', + 'CD gap error' ); + } + + public function testCentralDirectoryTruncated() { + $this->readZipAssertError( 'cd-truncated.zip', 'zip-bad', + 'CD truncated error (should hit unpack() overrun)' ); + } + + public function testLooksLikeZip64() { + $this->readZipAssertError( 'looks-like-zip64.zip', 'zip-unsupported', + 'A file which looks like ZIP64 but isn\'t, should give error' ); + } +} |