diff options
Diffstat (limited to 'tests/phpunit/includes/libs')
47 files changed, 12430 insertions, 0 deletions
diff --git a/tests/phpunit/includes/libs/ArrayUtilsTest.php b/tests/phpunit/includes/libs/ArrayUtilsTest.php new file mode 100644 index 000000000000..12b632056475 --- /dev/null +++ b/tests/phpunit/includes/libs/ArrayUtilsTest.php @@ -0,0 +1,308 @@ +<?php +/** + * Test class for ArrayUtils class + * + * @group Database + */ +class ArrayUtilsTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers ArrayUtils::findLowerBound + * @dataProvider provideFindLowerBound + */ + function testFindLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target, $expected + ) { + $this->assertSame( + ArrayUtils::findLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target + ), $expected + ); + } + + function provideFindLowerBound() { + $indexValueCallback = function ( $size ) { + return function ( $val ) use ( $size ) { + $this->assertTrue( $val >= 0 ); + $this->assertTrue( $val < $size ); + return $val; + }; + }; + $comparisonCallback = function ( $a, $b ) { + return $a - $b; + }; + + return [ + [ + $indexValueCallback( 0 ), + 0, + $comparisonCallback, + 1, + false, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + -1, + false, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 0, + 0, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 1, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + -1, + false, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0.5, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1, + 1, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1.5, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1.5, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 2, + 2, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 3, + 2, + ], + ]; + } + + /** + * @covers ArrayUtils::arrayDiffAssocRecursive + * @dataProvider provideArrayDiffAssocRecursive + */ + function testArrayDiffAssocRecursive( $expected, ...$args ) { + $this->assertEquals( call_user_func_array( + 'ArrayUtils::arrayDiffAssocRecursive', $args + ), $expected ); + } + + function provideArrayDiffAssocRecursive() { + return [ + [ + [], + [], + [], + ], + [ + [], + [], + [], + [], + ], + [ + [ 1 ], + [ 1 ], + [], + ], + [ + [ 1 ], + [ 1 ], + [], + [], + ], + [ + [], + [], + [ 1 ], + ], + [ + [], + [], + [ 1 ], + [ 2 ], + ], + [ + [ '' => 1 ], + [ '' => 1 ], + [], + ], + [ + [], + [], + [ '' => 1 ], + ], + [ + [ 1 ], + [ 1 ], + [ 2 ], + ], + [ + [], + [ 1 ], + [ 2 ], + [ 1 ], + ], + [ + [], + [ 1 ], + [ 1, 2 ], + ], + [ + [ 1 => 1 ], + [ 1 => 1 ], + [ 1 ], + ], + [ + [], + [ 1 => 1 ], + [ 1 ], + [ 1 => 1 ], + ], + [ + [], + [ 1 => 1 ], + [ 1, 1, 1 ], + ], + [ + [], + [ [] ], + [], + ], + [ + [], + [ [ [] ] ], + [], + ], + [ + [ 1, [ 1 ] ], + [ 1, [ 1 ] ], + [], + ], + [ + [ 1 ], + [ 1, [ 1 ] ], + [ 2, [ 1 ] ], + ], + [ + [], + [ 1, [ 1 ] ], + [ 2, [ 1 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 ], + [ 1, [] ], + [ 2 ], + ], + [ + [], + [ 1, [] ], + [ 2 ], + [ 1 ], + ], + [ + [ 1, [ 1 => 2 ] ], + [ 1, [ 1, 2 ] ], + [ 2, [ 1 ] ], + ], + [ + [ 1 ], + [ 1, [ 1, 2 ] ], + [ 2, [ 1 ] ], + [ 2, [ 1 => 2 ] ], + ], + [ + [ 1 => [ 1, 2 ] ], + [ 1, [ 1, 2 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 => [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 => [ [ 2 ], 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3 ] ] ], + ], + [ + [ 1 => [ 1 => 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3, 0 => 2 ] ] ], + ], + [ + [ 1 => [ 1 => 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3 ] ] ], + [ 1 => [ [ 2 ] ] ], + ], + [ + [], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1 => [ 1 => 2, 0 => [ 1 => 3, 0 => 2 ] ], 0 => 1 ], + ], + [ + [], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1 => [ 1 => 2 ] ], + [ 1 => [ [ 1 => 3 ] ] ], + [ 1 => [ [ 2 ] ] ], + [ 1 ], + ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/CookieTest.php b/tests/phpunit/includes/libs/CookieTest.php new file mode 100644 index 000000000000..e383be965069 --- /dev/null +++ b/tests/phpunit/includes/libs/CookieTest.php @@ -0,0 +1,52 @@ +<?php + +/** + * @covers Cookie + */ +class CookieTest extends \PHPUnit\Framework\TestCase { + + /** + * @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 [ + [ false, "org" ], + [ false, ".org" ], + [ true, "wikipedia.org" ], + [ true, ".wikipedia.org" ], + [ false, "co.uk" ], + [ false, ".co.uk" ], + [ false, "gov.uk" ], + [ false, ".gov.uk" ], + [ true, "supermarket.uk" ], + [ false, "uk" ], + [ false, ".uk" ], + [ false, "127.0.0." ], + [ false, "127." ], + [ false, "127.0.0.1." ], + [ true, "127.0.0.1" ], + [ false, "333.0.0.1" ], + [ true, "example.com" ], + [ false, "example.com." ], + [ true, ".example.com" ], + + [ true, ".example.com", "www.example.com" ], + [ false, "example.com", "www.example.com" ], + [ true, "127.0.0.1", "127.0.0.1" ], + [ false, "127.0.0.1", "localhost" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/DeferredStringifierTest.php b/tests/phpunit/includes/libs/DeferredStringifierTest.php new file mode 100644 index 000000000000..c9cdf5831ded --- /dev/null +++ b/tests/phpunit/includes/libs/DeferredStringifierTest.php @@ -0,0 +1,54 @@ +<?php + +/** + * @covers DeferredStringifier + */ +class DeferredStringifierTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @dataProvider provideToString + */ + public function testToString( $params, $expected ) { + $class = new ReflectionClass( DeferredStringifier::class ); + $ds = $class->newInstanceArgs( $params ); + $this->assertEquals( $expected, (string)$ds ); + } + + public static function provideToString() { + return [ + // No args + [ + [ + function () { + return 'foo'; + } + ], + 'foo' + ], + // Has args + [ + [ + function ( $i ) { + return $i; + }, + 'bar' + ], + 'bar' + ], + ]; + } + + /** + * Verify that the callback is not called if + * it is never converted to a string + */ + public function testCallbackNotCalled() { + $ds = new DeferredStringifier( function () { + throw new Exception( 'This should not be reached!' ); + } ); + // No exception was thrown + $this->assertTrue( true ); + } +} diff --git a/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php b/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php new file mode 100644 index 000000000000..1b3397c12ab0 --- /dev/null +++ b/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php @@ -0,0 +1,144 @@ +<?php + +/** + * @covers DnsSrvDiscoverer + */ +class DnsSrvDiscovererTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @dataProvider provideRecords + */ + public function testPickServer( $params, $expected ) { + $discoverer = new DnsSrvDiscoverer( 'etcd-tcp.example.net' ); + $record = $discoverer->pickServer( $params ); + + $this->assertEquals( $expected, $record ); + } + + public static function provideRecords() { + return [ + [ + [ // record list + [ + 'target' => 'conf03.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf02.example.net', + 'port' => 'SRV', + 'pri' => 1, + 'weight' => 1, + ], + [ + 'target' => 'conf01.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + ], // selected record + [ + 'target' => 'conf03.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ] + ], + [ + [ // record list + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf01.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf04.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 'SRV', + 'pri' => 3, + 'weight' => 1, + ], + ], // selected record + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ] + ], + ]; + } + + public function testRemoveServer() { + $dsd = new DnsSrvDiscoverer( 'localhost' ); + + $servers = [ + [ + 'target' => 'conf01.example.net', + 'port' => 35, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf04.example.net', + 'port' => 74, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 77, + 'pri' => 3, + 'weight' => 1, + ], + ]; + $server = $servers[1]; + + $expected = [ + [ + 'target' => 'conf01.example.net', + 'port' => 35, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 77, + 'pri' => 3, + 'weight' => 1, + ], + ]; + + $this->assertEquals( + $expected, + $dsd->removeServer( $server, $servers ), + "Correct server removed" + ); + $this->assertEquals( + $expected, + $dsd->removeServer( $server, $servers ), + "Nothing to remove" + ); + } +} diff --git a/tests/phpunit/includes/libs/EasyDeflateTest.php b/tests/phpunit/includes/libs/EasyDeflateTest.php new file mode 100644 index 000000000000..da39d48d901a --- /dev/null +++ b/tests/phpunit/includes/libs/EasyDeflateTest.php @@ -0,0 +1,64 @@ +<?php +/** + * Copyright (C) 2018 Kunal Mehta <legoktm@member.fsf.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. + * + */ + +/** + * @covers EasyDeflate + */ +class EasyDeflateTest extends PHPUnit\Framework\TestCase { + + public function provideIsDeflated() { + return [ + [ 'rawdeflate,S8vPT0osAgA=', true ], + [ 'abcdefghijklmnopqrstuvwxyz', false ], + ]; + } + + /** + * @dataProvider provideIsDeflated + */ + public function testIsDeflated( $data, $expected ) { + $actual = EasyDeflate::isDeflated( $data ); + $this->assertSame( $expected, $actual ); + } + + public function provideInflate() { + return [ + [ 'rawdeflate,S8vPT0osAgA=', true, 'foobar' ], + // Fails base64_decode + [ 'rawdeflate,🌻', false, 'easydeflate-invaliddeflate' ], + // Fails gzinflate + [ 'rawdeflate,S8vPT0dfdAgB=', false, 'easydeflate-invaliddeflate' ], + ]; + } + + /** + * @dataProvider provideInflate + */ + public function testInflate( $data, $ok, $value ) { + $actual = EasyDeflate::inflate( $data ); + if ( $ok ) { + $this->assertTrue( $actual->isOK() ); + $this->assertSame( $value, $actual->getValue() ); + } else { + $this->assertFalse( $actual->isOK() ); + $this->assertTrue( $actual->hasMessage( $value ) ); + } + } +} diff --git a/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/tests/phpunit/includes/libs/GenericArrayObjectTest.php new file mode 100644 index 000000000000..3be2b06465dc --- /dev/null +++ b/tests/phpunit/includes/libs/GenericArrayObjectTest.php @@ -0,0 +1,279 @@ +<?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 + * + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + */ +abstract class GenericArrayObjectTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * 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 = []; + + 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 = [] ) { + $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 === [], $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 + */ + protected function checkTypeChecks( $function ) { + $excption = null; + $list = $this->getNew(); + + $elementClass = $list->getObjectType(); + + foreach ( [ 42, 'foo', [], 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::getObjectType + * @covers GenericArrayObject::offsetSet + */ + public function testOffsetSet( array $elements ) { + if ( $elements === [] ) { + $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 000000000000..acaeb0255859 --- /dev/null +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -0,0 +1,327 @@ +<?php + +/** + * @group HashRing + * @covers HashRing + */ +class HashRingTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + public function testHashRingSerialize() { + $map = [ 's1' => 3, 's2' => 10, 's3' => 2, 's4' => 10, 's5' => 2, 's6' => 3 ]; + $ring = new HashRing( $map, 'md5' ); + + $serialized = serialize( $ring ); + $ringRemade = unserialize( $serialized ); + + for ( $i = 0; $i < 100; $i++ ) { + $this->assertEquals( + $ring->getLocation( "hello$i" ), + $ringRemade->getLocation( "hello$i" ), + 'Items placed at proper locations' + ); + } + } + + public function testHashRingMapping() { + // SHA-1 based and weighted + $ring = new HashRing( + [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3, 's7' => 0 ], + 'sha1' + ); + + $this->assertEquals( + [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ], + $ring->getLocationWeights(), + 'Normalized location weights' + ); + + $locations = []; + for ( $i = 0; $i < 25; $i++ ) { + $locations[ "hello$i"] = $ring->getLocation( "hello$i" ); + } + $expectedLocations = [ + "hello0" => "s4", + "hello1" => "s6", + "hello2" => "s3", + "hello3" => "s6", + "hello4" => "s6", + "hello5" => "s4", + "hello6" => "s3", + "hello7" => "s4", + "hello8" => "s3", + "hello9" => "s3", + "hello10" => "s3", + "hello11" => "s5", + "hello12" => "s4", + "hello13" => "s5", + "hello14" => "s2", + "hello15" => "s5", + "hello16" => "s6", + "hello17" => "s5", + "hello18" => "s1", + "hello19" => "s1", + "hello20" => "s6", + "hello21" => "s5", + "hello22" => "s3", + "hello23" => "s4", + "hello24" => "s1" + ]; + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + + $locations = []; + for ( $i = 0; $i < 5; $i++ ) { + $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 ); + } + + $expectedLocations = [ + "hello0" => [ "s4", "s5" ], + "hello1" => [ "s6", "s5" ], + "hello2" => [ "s3", "s1" ], + "hello3" => [ "s6", "s5" ], + "hello4" => [ "s6", "s3" ], + ]; + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + } + + /** + * @dataProvider providor_getHashLocationWeights + */ + public function testHashRingRatios( $locations, $expectedHits ) { + $ring = new HashRing( $locations, 'whirlpool' ); + + $locationStats = array_fill_keys( array_keys( $locations ), 0 ); + for ( $i = 0; $i < 10000; ++$i ) { + ++$locationStats[$ring->getLocation( "key-$i" )]; + } + $this->assertEquals( $expectedHits, $locationStats ); + } + + public static function providor_getHashLocationWeights() { + return [ + [ + [ 'big' => 10, 'medium' => 5, 'small' => 1 ], + [ 'big' => 6037, 'medium' => 3314, 'small' => 649 ] + ] + ]; + } + + /** + * @dataProvider providor_getHashLocationWeights2 + */ + public function testHashRingRatios2( $locations, $expected ) { + $ring = new HashRing( $locations, 'sha1' ); + $locationStats = array_fill_keys( array_keys( $locations ), 0 ); + for ( $i = 0; $i < 1000; ++$i ) { + foreach ( $ring->getLocations( "key-$i", 3 ) as $location ) { + ++$locationStats[$location]; + } + } + $this->assertEquals( $expected, $locationStats ); + } + + public static function providor_getHashLocationWeights2() { + return [ + [ + [ 'big1' => 10, 'big2' => 10, 'big3' => 10, 'small1' => 1, 'small2' => 1 ], + [ 'big1' => 929, 'big2' => 899, 'big3' => 887, 'small1' => 143, 'small2' => 142 ] + ] + ]; + } + + public function testHashRingEjection() { + $map = [ 's1' => 5, 's2' => 5, 's3' => 10, 's4' => 10, 's5' => 5, 's6' => 5 ]; + $ring = new HashRing( $map, 'md5' ); + + $ring->ejectFromLiveRing( 's3', 30 ); + $ring->ejectFromLiveRing( 's6', 15 ); + + $this->assertEquals( + [ 's1' => 5, 's2' => 5, 's4' => 10, 's5' => 5 ], + $ring->getLiveLocationWeights(), + 'Live location weights' + ); + + for ( $i = 0; $i < 100; ++$i ) { + $key = "key-$i"; + + $this->assertNotEquals( 's3', $ring->getLiveLocation( $key ), 'ejected' ); + $this->assertNotEquals( 's6', $ring->getLiveLocation( $key ), 'ejected' ); + + if ( !in_array( $ring->getLocation( $key ), [ 's3', 's6' ], true ) ) { + $this->assertEquals( + $ring->getLocation( $key ), + $ring->getLiveLocation( $key ), + "Live ring otherwise matches (#$i)" + ); + $this->assertEquals( + $ring->getLocations( $key, 1 ), + $ring->getLiveLocations( $key, 1 ), + "Live ring otherwise matches (#$i)" + ); + } + } + } + + public function testHashRingCollision() { + $ring1 = new HashRing( [ 0 => 1, 6497 => 1 ] ); + $ring2 = new HashRing( [ 6497 => 1, 0 => 1 ] ); + + for ( $i = 0; $i < 100; ++$i ) { + $this->assertEquals( $ring1->getLocation( $i ), $ring2->getLocation( $i ) ); + } + } + + public function testHashRingKetamaMode() { + // Same as https://github.com/RJ/ketama/blob/master/ketama.servers + $map = [ + '10.0.1.1:11211' => 600, + '10.0.1.2:11211' => 300, + '10.0.1.3:11211' => 200, + '10.0.1.4:11211' => 350, + '10.0.1.5:11211' => 1000, + '10.0.1.6:11211' => 800, + '10.0.1.7:11211' => 950, + '10.0.1.8:11211' => 100 + ]; + $ring = new HashRing( $map, 'md5' ); + $wrapper = \Wikimedia\TestingAccessWrapper::newFromObject( $ring ); + + $ketama_test = function ( $count ) use ( $wrapper ) { + $baseRing = $wrapper->baseRing; + + $lines = []; + for ( $key = 0; $key < $count; ++$key ) { + $location = $wrapper->getLocation( $key ); + + $itemPos = $wrapper->getItemPosition( $key ); + $nodeIndex = $wrapper->findNodeIndexForPosition( $itemPos, $baseRing ); + $nodePos = $baseRing[$nodeIndex][HashRing::KEY_POS]; + + $lines[] = sprintf( "%u %u %s\n", $itemPos, $nodePos, $location ); + } + + return "\n" . implode( '', $lines ); + }; + + // Known correct values generated from C code: + // https://github.com/RJ/ketama/blob/master/libketama/ketama_test.c + $expected = <<<EOT + +2216742351 2217271743 10.0.1.1:11211 +943901380 949045552 10.0.1.5:11211 +2373066440 2374693370 10.0.1.6:11211 +2127088620 2130338203 10.0.1.6:11211 +2046197672 2051996197 10.0.1.7:11211 +2134629092 2135172435 10.0.1.1:11211 +470382870 472541453 10.0.1.7:11211 +1608782991 1609789509 10.0.1.3:11211 +2516119753 2520092206 10.0.1.2:11211 +3465331781 3466294492 10.0.1.4:11211 +1749342675 1753760600 10.0.1.5:11211 +1136464485 1137779711 10.0.1.1:11211 +3620997826 3621580689 10.0.1.7:11211 +283385029 285581365 10.0.1.6:11211 +2300818346 2302165654 10.0.1.5:11211 +2132603803 2134614475 10.0.1.8:11211 +2962705863 2969767984 10.0.1.2:11211 +786427760 786565633 10.0.1.5:11211 +4095887727 4096760944 10.0.1.6:11211 +2906459679 2906987515 10.0.1.6:11211 +137884056 138922607 10.0.1.4:11211 +81549628 82491298 10.0.1.6:11211 +3530020790 3530525869 10.0.1.6:11211 +4231817527 4234960467 10.0.1.7:11211 +2011099423 2014738083 10.0.1.7:11211 +107620750 120968799 10.0.1.6:11211 +3979113294 3981926993 10.0.1.4:11211 +273671938 276355738 10.0.1.4:11211 +4032816947 4033300359 10.0.1.5:11211 +464234862 466093615 10.0.1.1:11211 +3007059764 3007671127 10.0.1.5:11211 +542337729 542491760 10.0.1.7:11211 +4040385635 4044064727 10.0.1.5:11211 +3319802648 3320661601 10.0.1.7:11211 +1032153571 1035085391 10.0.1.1:11211 +3543939100 3545608820 10.0.1.5:11211 +3876899353 3885324049 10.0.1.2:11211 +3771318181 3773259708 10.0.1.8:11211 +3457906597 3459285639 10.0.1.5:11211 +3028975062 3031083168 10.0.1.7:11211 +244467158 250943416 10.0.1.5:11211 +1604785716 1609789509 10.0.1.3:11211 +3905343649 3905751132 10.0.1.1:11211 +1713497623 1725056963 10.0.1.5:11211 +1668356087 1668827816 10.0.1.5:11211 +3427369836 3438933308 10.0.1.1:11211 +2515850457 2520092206 10.0.1.2:11211 +3886138983 3887390208 10.0.1.1:11211 +4019334756 4023153300 10.0.1.8:11211 +1170561012 1170785765 10.0.1.7:11211 +1841809344 1848425105 10.0.1.6:11211 +973223976 973369204 10.0.1.1:11211 +358093210 359562433 10.0.1.6:11211 +378350808 380841931 10.0.1.5:11211 +4008477862 4012085095 10.0.1.7:11211 +1027226549 1028630030 10.0.1.6:11211 +2386583967 2387706118 10.0.1.1:11211 +522892146 524831677 10.0.1.7:11211 +3779194982 3788912803 10.0.1.5:11211 +3764731657 3771312500 10.0.1.7:11211 +184756999 187529415 10.0.1.6:11211 +838351231 845886003 10.0.1.3:11211 +2827220548 2828019973 10.0.1.6:11211 +3604721411 3607668249 10.0.1.6:11211 +472866282 475506254 10.0.1.5:11211 +2752268796 2754833471 10.0.1.5:11211 +1791464754 1795042583 10.0.1.7:11211 +3029359475 3031083168 10.0.1.7:11211 +3633378211 3639985542 10.0.1.6:11211 +3148267284 3149217023 10.0.1.6:11211 +163887996 166705043 10.0.1.7:11211 +3642803426 3649125922 10.0.1.7:11211 +3901799218 3902199881 10.0.1.7:11211 +418045394 425867331 10.0.1.6:11211 +346775981 348578169 10.0.1.6:11211 +368352208 372224616 10.0.1.7:11211 +2643711995 2644259911 10.0.1.5:11211 +2032983336 2033860601 10.0.1.6:11211 +3567842357 3572867530 10.0.1.2:11211 +1024982737 1028630030 10.0.1.6:11211 +933966832 938106828 10.0.1.7:11211 +2102520899 2103402846 10.0.1.7:11211 +3537205399 3538094881 10.0.1.7:11211 +2311233534 2314593262 10.0.1.1:11211 +2500514664 2503565236 10.0.1.7:11211 +1091958846 1093484995 10.0.1.6:11211 +3984972691 3987453644 10.0.1.1:11211 +2669994439 2670911201 10.0.1.4:11211 +2846111786 2846115813 10.0.1.5:11211 +1805010806 1808593732 10.0.1.8:11211 +1587024774 1587746378 10.0.1.5:11211 +3214549588 3215619351 10.0.1.2:11211 +1965214866 1970922428 10.0.1.7:11211 +1038671000 1040777775 10.0.1.7:11211 +820820468 823114475 10.0.1.6:11211 +2722835329 2723166435 10.0.1.5:11211 +1602053414 1604196066 10.0.1.5:11211 +1330835426 1335097278 10.0.1.5:11211 +556547565 557075710 10.0.1.4:11211 +2977587884 2978402952 10.0.1.1:11211 + +EOT; + + $this->assertEquals( $expected, $ketama_test( 100 ), 'Ketama mode (diff check)' ); + + // Hash of known correct values from C code + $this->assertEquals( + 'c69ac9eb7a8a630c0cded201cefeaace', + md5( $ketama_test( 1e5 ) ), + 'Ketama mode (large, MD5 check)' + ); + + // Slower, full upstream MD5 check, manually verified 3/21/2018 + // $this->assertEquals( '5672b131391f5aa2b280936aec1eea74', md5( $ketama_test( 1e6 ) ) ); + } +} diff --git a/tests/phpunit/includes/libs/HtmlArmorTest.php b/tests/phpunit/includes/libs/HtmlArmorTest.php new file mode 100644 index 000000000000..c5e87e4e2aaa --- /dev/null +++ b/tests/phpunit/includes/libs/HtmlArmorTest.php @@ -0,0 +1,55 @@ +<?php + +/** + * @covers HtmlArmor + */ +class HtmlArmorTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + public static function provideConstructor() { + return [ + [ 'test' ], + [ null ], + [ '<em>some html!</em>' ] + ]; + } + + /** + * @dataProvider provideConstructor + */ + public function testConstructor( $value ) { + $this->assertInstanceOf( HtmlArmor::class, new HtmlArmor( $value ) ); + } + + public static function provideGetHtml() { + return [ + [ + 'foobar', + 'foobar', + ], + [ + '<script>alert("evil!");</script>', + '<script>alert("evil!");</script>', + ], + [ + new HtmlArmor( '<script>alert("evil!");</script>' ), + '<script>alert("evil!");</script>', + ], + [ + new HtmlArmor( null ), + null, + ] + ]; + } + + /** + * @dataProvider provideGetHtml + */ + public function testGetHtml( $input, $expected ) { + $this->assertEquals( + $expected, + HtmlArmor::getHtml( $input ) + ); + } +} diff --git a/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/tests/phpunit/includes/libs/IEUrlExtensionTest.php new file mode 100644 index 000000000000..e04b2e21bf5d --- /dev/null +++ b/tests/phpunit/includes/libs/IEUrlExtensionTest.php @@ -0,0 +1,42 @@ +<?php + +class IEUrlExtensionTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + public function provideFindIE6Extension() { + return [ + // url, expected, message + [ 'x.y', 'y', 'Simple extension' ], + [ 'x', '', 'No extension' ], + [ '', '', 'Empty string' ], + [ '?', '', 'Question mark only' ], + [ '.x?', 'x', 'Extension then question mark' ], + [ '?.x', 'x', 'Question mark then extension' ], + [ '.x*', '', 'Extension with invalid character' ], + [ '*.x', 'x', 'Invalid character followed by an extension' ], + [ 'a?b?.c?.d?e?f', 'c', 'Multiple question marks' ], + [ 'a?b?.exe?.d?.e', 'd', '.exe exception' ], + [ 'a?b?.exe', 'exe', '.exe exception 2' ], + [ 'a#b.c', '', 'Hash character preceding extension' ], + [ 'a?#b.c', '', 'Hash character preceding extension 2' ], + [ '.', '', 'Dot at end of string' ], + [ 'x.y.z', 'z', 'Two dots' ], + [ 'example.php?foo=a&bar=b', 'php', 'Script with query' ], + [ 'example%2Ephp?foo=a&bar=b', '', 'Script with urlencoded dot and query' ], + [ 'example%2Ephp?foo=a.x&bar=b.y', 'y', 'Script with urlencoded dot and query with dot' ], + ]; + } + + /** + * @covers IEUrlExtension::findIE6Extension + * @dataProvider provideFindIE6Extension + */ + public function testFindIE6Extension( $url, $expected, $message ) { + $this->assertEquals( + $expected, + IEUrlExtension::findIE6Extension( $url ), + $message + ); + } +} diff --git a/tests/phpunit/includes/libs/IPTest.php b/tests/phpunit/includes/libs/IPTest.php new file mode 100644 index 000000000000..9ec53c00c773 --- /dev/null +++ b/tests/phpunit/includes/libs/IPTest.php @@ -0,0 +1,673 @@ +<?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 PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers IP::isIPAddress + * @dataProvider provideInvalidIPs + */ + public function testIsNotIPAddress( $val, $desc ) { + $this->assertFalse( IP::isIPAddress( $val ), $desc ); + } + + /** + * Provide a list of things that aren't IP addresses + */ + public function provideInvalidIPs() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Garbage IP string' ], + [ ':', 'Single ":" is not an IP' ], + [ '2001:0DB8::A:1::1', 'IPv6 with a double :: occurrence' ], + [ '2001:0DB8::A:1::', 'IPv6 with a double :: occurrence, last at end' ], + [ '::2001:0DB8::5:1', 'IPv6 with a double :: occurrence, firt at beginning' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + [ 'fc:100:300', 'IPv6 with only 3 words' ], + ]; + } + + /** + * @covers IP::isIPAddress + */ + public function testisIPAddress() { + $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 = [ '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 + * @dataProvider provideInvalidIPv4Addresses + */ + public function testisNotIPv4( $bogusIP, $desc ) { + $this->assertFalse( IP::isIPv4( $bogusIP ), $desc ); + } + + public function provideInvalidIPv4Addresses() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Letters are not an IP' ], + [ ':', 'A colon is not an IP' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + ]; + } + + /** + * @covers IP::isIPv4 + * @dataProvider provideValidIPv4Address + */ + public function testIsIPv4( $ip, $desc ) { + $this->assertTrue( IP::isIPv4( $ip ), $desc ); + } + + /** + * Provide some IPv4 addresses and ranges + */ + public function provideValidIPv4Address() { + return [ + [ '124.24.52.13', 'Valid IPv4 address' ], + [ '1.24.52.13', 'Another valid IPv4 address' ], + [ '74.24.52.13/20', 'An 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( [ $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( [ $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( [ $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( [ $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 = [ + '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 range" ); + } + // Incomplete/garbage + $invalid = [ + '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" ); + } + } + + /** + * Provide some valid IP ranges + */ + public function provideValidRanges() { + return [ + [ '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' ], + ]; + } + + /** + * @covers IP::isValidRange + * @dataProvider provideValidRanges + */ + public function testValidRanges( $range ) { + $this->assertTrue( IP::isValidRange( $range ), "$range is a valid IP range" ); + } + + /** + * @covers IP::isValidRange + * @dataProvider provideInvalidRanges + */ + public function testInvalidRanges( $invalid ) { + $this->assertFalse( IP::isValidRange( $invalid ), "$invalid is not a valid IP range" ); + } + + public function provideInvalidRanges() { + return [ + [ '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/' ], + ]; + } + + /** + * @covers IP::sanitizeIP + * @dataProvider provideSanitizeIP + */ + public function testSanitizeIP( $expected, $input ) { + $result = IP::sanitizeIP( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testSanitizeIP() + */ + public static function provideSanitizeIP() { + return [ + [ '0.0.0.0', '0.0.0.0' ], + [ '0.0.0.0', '00.00.00.00' ], + [ '0.0.0.0', '000.000.000.000' ], + [ '0.0.0.0/24', '000.000.000.000/24' ], + [ '141.0.11.253', '141.000.011.253' ], + [ '1.2.4.5', '1.2.4.5' ], + [ '1.2.4.5', '01.02.04.05' ], + [ '1.2.4.5', '001.002.004.005' ], + [ '10.0.0.1', '010.0.000.1' ], + [ '80.72.250.4', '080.072.250.04' ], + [ 'Foo.1000.00', 'Foo.1000.00' ], + [ 'Bar.01', 'Bar.01' ], + [ 'Bar.010', 'Bar.010' ], + [ null, '' ], + [ null, ' ' ] + ]; + } + + /** + * @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 [ + [ '00000001', '0.0.0.1' ], + [ '01020304', '1.2.3.4' ], + [ '7F000001', '127.0.0.1' ], + [ '80000000', '128.0.0.0' ], + [ 'DEADCAFE', '222.173.202.254' ], + [ 'FFFFFFFF', '255.255.255.255' ], + [ '8D000BFD', '141.000.11.253' ], + [ false, 'IN.VA.LI.D' ], + [ 'v6-00000000000000000000000000000001', '::1' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ], + [ false, 'IN:VA::LI:D' ], + [ false, ':::1' ] + ]; + } + + /** + * @covers IP::isPublic + * @dataProvider provideIsPublic + */ + public function testIsPublic( $expected, $input ) { + $result = IP::isPublic( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testIsPublic() + */ + public static function provideIsPublic() { + return [ + [ false, 'fc00::3' ], # RFC 4193 (local) + [ false, 'fc00::ff' ], # RFC 4193 (local) + [ false, '127.1.2.3' ], # loopback + [ false, '::1' ], # loopback + [ false, 'fe80::1' ], # link-local + [ false, '169.254.1.1' ], # link-local + [ false, '10.0.0.1' ], # RFC 1918 (private) + [ false, '172.16.0.1' ], # RFC 1918 (private) + [ false, '192.168.0.1' ], # RFC 1918 (private) + [ true, '2001:5c0:1000:a::133' ], # public + [ true, 'fc::3' ], # public + [ true, '00FC::' ] # public + ]; + } + + // Private wrapper used to test CIDR Parsing. + private function assertFalseCIDR( $CIDR, $msg = '' ) { + $ff = [ 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 + * @dataProvider provideIPsAndHexes + */ + public function testHexToQuad( $ip, $hex ) { + $this->assertEquals( $ip, IP::hexToQuad( $hex ) ); + } + + /** + * Provide some IP addresses and their equivalent hex representations + */ + public function provideIPsandHexes() { + return [ + [ '0.0.0.1', '00000001' ], + [ '255.0.0.0', 'FF000000' ], + [ '255.255.255.255', 'FFFFFFFF' ], + [ '10.188.222.255', '0ABCDEFF' ], + // hex not left-padded... + [ '0.0.0.0', '0' ], + [ '0.0.0.1', '1' ], + [ '0.0.0.255', 'FF' ], + [ '0.0.255.0', 'FF00' ], + ]; + } + + /** + * @covers IP::hexToOctet + * @dataProvider provideOctetsAndHexes + */ + public function testHexToOctet( $octet, $hex ) { + $this->assertEquals( $octet, IP::hexToOctet( $hex ) ); + } + + /** + * Provide some hex and octet representations of the same IPs + */ + public function provideOctetsAndHexes() { + return [ + [ '0:0:0:0:0:0:0:1', '00000000000000000000000000000001' ], + [ '0:0:0:0:0:0:FF:3', '00000000000000000000000000FF0003' ], + [ '0:0:0:0:0:0:FF00:6', '000000000000000000000000FF000006' ], + [ '0:0:0:0:0:0:FCCF:FAFF', '000000000000000000000000FCCFFAFF' ], + [ 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ], + // hex not left-padded... + [ '0:0:0:0:0:0:0:0', '0' ], + [ '0:0:0:0:0:0:0:1', '1' ], + [ '0:0:0:0:0:0:0:FF', 'FF' ], + [ '0:0:0:0:0:0:0:FFD0', 'FFD0' ], + [ '0:0:0:0:0:0:FA00:0', 'FA000000' ], + [ '0:0:0:0:0:0:FCCF:FAFF', '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( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); + $this->assertEquals( [ 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 [ + # IPv4 + [ true, '192.0.2.0', '192.0.2.0/24', 'Network address' ], + [ true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ], + [ true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ], + + [ false, '0.0.0.0', '192.0.2.0/24' ], + [ false, '255.255.255', '192.0.2.0/24' ], + + # IPv6 + [ false, '::1', '2001:DB8::/32' ], + [ false, '::', '2001:DB8::/32' ], + [ false, 'FE80::1', '2001:DB8::/32' ], + + [ true, '2001:DB8::', '2001:DB8::/32' ], + [ true, '2001:0DB8::', '2001:DB8::/32' ], + [ true, '2001:DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + '2001:DB8::/32' ], + + [ false, '2001:0DB8:F::', '2001:DB8::/96' ], + ]; + } + + /** + * @covers 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 [ + [ false, '[', 'Unclosed square bracket' ], + [ false, '[::', 'Unclosed square bracket 2' ], + [ [ '::', false ], '::', 'Bare IPv6 0' ], + [ [ '::1', false ], '::1', 'Bare IPv6 1' ], + [ [ '::', false ], '[::]', 'Bracketed IPv6 0' ], + [ [ '::1', false ], '[::1]', 'Bracketed IPv6 1' ], + [ [ '::1', 80 ], '[::1]:80', 'Bracketed IPv6 with port' ], + [ false, '::x', 'Double colon but no IPv6' ], + [ [ 'x', 80 ], 'x:80', 'Hostname and port' ], + [ false, 'x:x', 'Hostname and invalid port' ], + [ [ 'x', false ], 'x', 'Plain hostname' ] + ]; + } + + /** + * @covers 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 [ + [ '[::1]', [ '::1', 2, 2 ], 'IPv6 default port' ], + [ '[::1]:2', [ '::1', 2, 3 ], 'IPv6 non-default port' ], + [ 'x', [ 'x', 2, 2 ], 'Normal default port' ], + [ 'x:2', [ 'x', 2, 3 ], 'Normal non-default port' ], + ]; + } + + /** + * @covers 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 [ + [ '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ], + [ '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ], + [ '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ], + [ '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ], + [ '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ], + [ '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ], + [ '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ], + [ '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ], + ]; + } + + /** + * @covers 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 [ + [ '0:0:0:0:0:0:0:0', '::' ], + [ '0:0:0::0:0:0', '::' ], + [ '0:0:0:1:0:0:0:0', '0:0:0:1::' ], + [ '0:0::f', '::f' ], + [ '0::0:0:0:33:fef:b', '::33:fef:b' ], + [ '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ], + [ '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ], + [ 'abbc:2004::0:0:0:0', 'abbc:2004::' ], + [ 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ], + [ '0:0:0:0:0:0:0:0/16', '::/16' ], + [ '0:0:0::0:0:0/64', '::/64' ], + [ '0:0::f/52', '::f/52' ], + [ '::0:0:33:fef:b/52', '::33:fef:b/52' ], + [ '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ], + [ '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ], + [ 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ], + [ 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php new file mode 100644 index 000000000000..d57d0dd553cc --- /dev/null +++ b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php @@ -0,0 +1,367 @@ +<?php + +class JavaScriptMinifierTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + protected function tearDown() { + parent::tearDown(); + // Reset + $this->setMaxLineLength( 1000 ); + } + + private function setMaxLineLength( $val ) { + $classReflect = new ReflectionClass( JavaScriptMinifier::class ); + $propertyReflect = $classReflect->getProperty( 'maxLineLength' ); + $propertyReflect->setAccessible( true ); + $propertyReflect->setValue( JavaScriptMinifier::class, $val ); + } + + public static function provideCases() { + return [ + + // Basic whitespace and comments that should be stripped entirely + [ "\r\t\f \v\n\r", "" ], + [ "/* Foo *\n*bar\n*/", "" ], + + /** + * Slashes used inside block comments (T28931). + * 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. + */ + [ + "/**\n * Foo\n * {\n * 'bar' : {\n * " + . "//Multiple rules with configurable operators\n * 'baz' : false\n * }\n */", + "" ], + + /** + * ' Foo \' bar \ + * baz \' quox ' . + */ + [ + "' Foo \\' bar \\\n baz \\' quox ' .length", + "' Foo \\' bar \\\n baz \\' quox '.length" + ], + [ + "\" Foo \\\" bar \\\n baz \\\" quox \" .length", + "\" Foo \\\" bar \\\n baz \\\" quox \".length" + ], + [ "// Foo b/ar baz", "" ], + [ + "/ Foo \\/ bar [ / \\] / ] baz / .length", + "/ Foo \\/ bar [ / \\] / ] baz /.length" + ], + + // HTML comments + [ "<!-- Foo bar", "" ], + [ "<!-- Foo --> bar", "" ], + [ "--> Foo", "" ], + [ "x --> y", "x-->y" ], + + // Semicolon insertion + [ "(function(){return\nx;})", "(function(){return\nx;})" ], + [ "throw\nx;", "throw\nx;" ], + [ "while(p){continue\nx;}", "while(p){continue\nx;}" ], + [ "while(p){break\nx;}", "while(p){break\nx;}" ], + [ "var\nx;", "var x;" ], + [ "x\ny;", "x\ny;" ], + [ "x\n++y;", "x\n++y;" ], + [ "x\n!y;", "x\n!y;" ], + [ "x\n{y}", "x\n{y}" ], + [ "x\n+y;", "x+y;" ], + [ "x\n(y);", "x(y);" ], + [ "5.\nx;", "5.\nx;" ], + [ "0xFF.\nx;", "0xFF.x;" ], + [ "5.3.\nx;", "5.3.x;" ], + + // Cover failure case for incomplete hex literal + [ "0x;", false, false ], + + // Cover failure case for number with no digits after E + [ "1.4E", false, false ], + + // Cover failure case for number with several E + [ "1.4EE2", false, false ], + [ "1.4EE", false, false ], + + // Cover failure case for number with several E (nonconsecutive) + // FIXME: This is invalid, but currently tolerated + [ "1.4E2E3", "1.4E2 E3", false ], + + // Semicolon insertion between an expression having an inline + // comment after it, and a statement on the next line (T29046). + [ + "var a = this //foo bar \n for ( b = 0; c < d; b++ ) {}", + "var a=this\nfor(b=0;c<d;b++){}" + ], + + // Cover failure case of incomplete regexp at end of file (T75556) + // FIXME: This is invalid, but currently tolerated + [ "*/", "*/", false ], + + // Cover failure case of incomplete char class in regexp (T75556) + // FIXME: This is invalid, but currently tolerated + [ "/a[b/.test", "/a[b/.test", false ], + + // Cover failure case of incomplete string at end of file (T75556) + // FIXME: This is invalid, but currently tolerated + [ "'a", "'a", false ], + + // Token separation + [ "x in y", "x in y" ], + [ "/x/g in y", "/x/g in y" ], + [ "x in 30", "x in 30" ], + [ "x + ++ y", "x+ ++y" ], + [ "x ++ + y", "x++ +y" ], + [ "x / /y/.exec(z)", "x/ /y/.exec(z)" ], + + // State machine + [ "/ x/g", "/ x/g" ], + [ "(function(){return/ x/g})", "(function(){return/ x/g})" ], + [ "+/ x/g", "+/ x/g" ], + [ "++/ x/g", "++/ x/g" ], + [ "x/ x/g", "x/x/g" ], + [ "(/ x/g)", "(/ x/g)" ], + [ "if(/ x/g);", "if(/ x/g);" ], + [ "(x/ x/g)", "(x/x/g)" ], + [ "([/ x/g])", "([/ x/g])" ], + [ "+x/ x/g", "+x/x/g" ], + [ "{}/ x/g", "{}/ x/g" ], + [ "+{}/ x/g", "+{}/x/g" ], + [ "(x)/ x/g", "(x)/x/g" ], + [ "if(x)/ x/g", "if(x)/ x/g" ], + [ "for(x;x;{}/ x/g);", "for(x;x;{}/x/g);" ], + [ "x;x;{}/ x/g", "x;x;{}/ x/g" ], + [ "x:{}/ x/g", "x:{}/ x/g" ], + [ "switch(x){case y?z:{}/ x/g:{}/ x/g;}", "switch(x){case y?z:{}/x/g:{}/ x/g;}" ], + [ "function x(){}/ x/g", "function x(){}/ x/g" ], + [ "+function x(){}/ x/g", "+function x(){}/x/g" ], + + // Multiline quoted string + [ "var foo=\"\\\nblah\\\n\";", "var foo=\"\\\nblah\\\n\";" ], + + // Multiline quoted string followed by string with spaces + [ + "var foo=\"\\\nblah\\\n\";\nvar baz = \" foo \";\n", + "var foo=\"\\\nblah\\\n\";var baz=\" foo \";" + ], + + // URL in quoted string ( // is not a comment) + [ + "aNode.setAttribute('href','http://foo.bar.org/baz');", + "aNode.setAttribute('href','http://foo.bar.org/baz');" + ], + + // URL in quoted string after multiline quoted string + [ + "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 + [ + "alert( (10+10) / '/'.charCodeAt( 0 ) + '//' );", + "alert((10+10)/'/'.charCodeAt(0)+'//');" + ], + [ "if(1)/a /g.exec('Pa ss');", "if(1)/a /g.exec('Pa ss');" ], + + // Unicode letter characters should pass through ok in identifiers (T33187) + [ "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. + [ "var Ka\\u015dSkatolVal = {}", 'var Ka\\u015dSkatolVal={}' ], + + // Some structures that might look invalid at first sight + [ "var a = 5.;", "var a=5.;" ], + [ "5.0.toString();", "5.0.toString();" ], + [ "5..toString();", "5..toString();" ], + // Cover failure case for too many decimal points + [ "5...toString();", false ], + [ "5.\n.toString();", '5..toString();' ], + + // Boolean minification (!0 / !1) + [ "var a = { b: true };", "var a={b:!0};" ], + [ "var a = { true: 12 };", "var a={true:12};" ], + [ "a.true = 12;", "a.true=12;" ], + [ "a.foo = true;", "a.foo=!0;" ], + [ "a.foo = false;", "a.foo=!1;" ], + ]; + } + + /** + * @dataProvider provideCases + * @covers JavaScriptMinifier::minify + * @covers JavaScriptMinifier::parseError + */ + public function testMinifyOutput( $code, $expectedOutput, $expectedValid = true ) { + $minified = JavaScriptMinifier::minify( $code ); + + // JSMin+'s parser will throw an exception if output is not valid JS. + // suppression of warnings needed for stupid crap + if ( $expectedValid ) { + Wikimedia\suppressWarnings(); + $parser = new JSParser(); + Wikimedia\restoreWarnings(); + $parser->parse( $minified, 'minify-test.js', 1 ); + } + + $this->assertEquals( + $expectedOutput, + $minified, + "Minified output should be in the form expected." + ); + } + + public static function provideLineBreaker() { + return [ + [ + // Regression tests for T34548. + // Must not break between 'E' and '+'. + 'var name = 1.23456789E55;', + [ + 'var', + 'name', + '=', + '1.23456789E55', + ';', + ], + ], + [ + 'var name = 1.23456789E+5;', + [ + 'var', + 'name', + '=', + '1.23456789E+5', + ';', + ], + ], + [ + 'var name = 1.23456789E-5;', + [ + 'var', + 'name', + '=', + '1.23456789E-5', + ';', + ], + ], + [ + // Must not break before '++' + 'if(x++);', + [ + 'if', + '(', + 'x++', + ')', + ';', + ], + ], + [ + // Regression test for T201606. + // Must not break between 'return' and Expression. + // Was caused by bad state after '{}' in property value. + <<<JAVASCRIPT + call( function () { + try { + } catch (e) { + obj = { + key: 1 ? 0 : {} + }; + } + return name === 'input'; + } ); +JAVASCRIPT + , + [ + 'call', + '(', + 'function', + '(', + ')', + '{', + 'try', + '{', + '}', + 'catch', + '(', + 'e', + ')', + '{', + 'obj', + '=', + '{', + 'key', + ':', + '1', + '?', + '0', + ':', + '{', + '}', + '}', + ';', + '}', + // The return Statement: + // return [no LineTerminator here] Expression + 'return name', + '===', + "'input'", + ';', + '}', + ')', + ';', + ] + ], + [ + // Regression test for T201606. + // Must not break between 'return' and Expression. + // Was caused by bad state after a ternary in the expression value + // for a key in an object literal. + <<<JAVASCRIPT +call( { + key: 1 ? 0 : function () { + return this; + } +} ); +JAVASCRIPT + , + [ + 'call', + '(', + '{', + 'key', + ':', + '1', + '?', + '0', + ':', + 'function', + '(', + ')', + '{', + 'return this', + ';', + '}', + '}', + ')', + ';', + ] + ], + ]; + } + + /** + * @dataProvider provideLineBreaker + * @covers JavaScriptMinifier::minify + */ + public function testLineBreaker( $code, array $expectedLines ) { + $this->setMaxLineLength( 1 ); + $actual = JavaScriptMinifier::minify( $code ); + $this->assertEquals( + array_merge( [ '' ], $expectedLines ), + explode( "\n", $actual ) + ); + } +} diff --git a/tests/phpunit/includes/libs/MapCacheLRUTest.php b/tests/phpunit/includes/libs/MapCacheLRUTest.php new file mode 100644 index 000000000000..7147c6fa224a --- /dev/null +++ b/tests/phpunit/includes/libs/MapCacheLRUTest.php @@ -0,0 +1,267 @@ +<?php +/** + * @group Cache + */ +class MapCacheLRUTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers MapCacheLRU::newFromArray() + * @covers MapCacheLRU::toArray() + * @covers MapCacheLRU::getAllKeys() + * @covers MapCacheLRU::clear() + * @covers MapCacheLRU::getMaxSize() + * @covers MapCacheLRU::setMaxSize() + */ + function testArrayConversion() { + $raw = [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $this->assertEquals( 3, $cache->getMaxSize() ); + $this->assertSame( true, $cache->has( 'a' ) ); + $this->assertSame( true, $cache->has( 'b' ) ); + $this->assertSame( true, $cache->has( 'c' ) ); + $this->assertSame( 1, $cache->get( 'a' ) ); + $this->assertSame( 2, $cache->get( 'b' ) ); + $this->assertSame( 3, $cache->get( 'c' ) ); + + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + $this->assertSame( + [ 'a', 'b', 'c' ], + $cache->getAllKeys() + ); + + $cache->clear( 'a' ); + $this->assertSame( + [ 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $cache->clear(); + $this->assertSame( + [], + $cache->toArray() + ); + + $cache = MapCacheLRU::newFromArray( [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], 4 ); + $cache->setMaxSize( 3 ); + $this->assertSame( + [ 'c' => 3, 'b' => 2, 'a' => 1 ], + $cache->toArray() + ); + } + + /** + * @covers MapCacheLRU::serialize() + * @covers MapCacheLRU::unserialize() + */ + function testSerialize() { + $cache = MapCacheLRU::newFromArray( [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], 10 ); + $string = serialize( $cache ); + $ncache = unserialize( $string ); + $this->assertSame( + [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], + $ncache->toArray() + ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + */ + function testLRU() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $this->assertSame( true, $cache->has( 'c' ) ); + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $this->assertSame( 3, $cache->get( 'c' ) ); + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $this->assertSame( 1, $cache->get( 'a' ) ); + $this->assertSame( + [ 'b' => 2, 'c' => 3, 'a' => 1 ], + $cache->toArray() + ); + + $cache->set( 'a', 1 ); + $this->assertSame( + [ 'b' => 2, 'c' => 3, 'a' => 1 ], + $cache->toArray() + ); + + $cache->set( 'b', 22 ); + $this->assertSame( + [ 'c' => 3, 'a' => 1, 'b' => 22 ], + $cache->toArray() + ); + + $cache->set( 'd', 4 ); + $this->assertSame( + [ 'a' => 1, 'b' => 22, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'e', 5, 0.33 ); + $this->assertSame( + [ 'e' => 5, 'b' => 22, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'f', 6, 0.66 ); + $this->assertSame( + [ 'b' => 22, 'f' => 6, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'g', 7, 0.90 ); + $this->assertSame( + [ 'f' => 6, 'g' => 7, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'g', 7, 1.0 ); + $this->assertSame( + [ 'f' => 6, 'd' => 4, 'g' => 7 ], + $cache->toArray() + ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + */ + public function testExpiry() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->set( 'd', 'xxx' ); + $this->assertTrue( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + + $now += 29; + $this->assertTrue( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + $this->assertEquals( 'xxx', $cache->get( 'd', 30 ) ); + + $now += 1.5; + $this->assertFalse( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + $this->assertNull( $cache->get( 'd', 30 ) ); + } + + /** + * @covers MapCacheLRU::hasField() + * @covers MapCacheLRU::getField() + * @covers MapCacheLRU::setField() + */ + public function testFields() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->setField( 'PMs', 'Tony Blair', 'Labour' ); + $cache->setField( 'PMs', 'Margaret Thatcher', 'Tory' ); + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + + $now += 29; + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair', 30 ) ); + + $now += 1.5; + $this->assertFalse( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertNull( $cache->getField( 'PMs', 'Tony Blair', 30 ) ); + + $this->assertEquals( + [ 'Tony Blair' => 'Labour', 'Margaret Thatcher' => 'Tory' ], + $cache->get( 'PMs' ) + ); + + $cache->set( 'MPs', [ + 'Edwina Currie' => 1983, + 'Neil Kinnock' => 1970 + ] ); + $this->assertEquals( + [ + 'Edwina Currie' => 1983, + 'Neil Kinnock' => 1970 + ], + $cache->get( 'MPs' ) + ); + + $this->assertEquals( 1983, $cache->getField( 'MPs', 'Edwina Currie' ) ); + $this->assertEquals( 1970, $cache->getField( 'MPs', 'Neil Kinnock' ) ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + * @covers MapCacheLRU::hasField() + * @covers MapCacheLRU::getField() + * @covers MapCacheLRU::setField() + */ + public function testInvalidKeys() { + $cache = MapCacheLRU::newFromArray( [], 3 ); + + try { + $cache->has( 3.4 ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->get( false ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->set( 3.4, 'x' ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + + try { + $cache->hasField( 'x', 3.4 ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->getField( 'x', false ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->setField( 'x', 3.4, 'x' ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + } +} diff --git a/tests/phpunit/includes/libs/MemoizedCallableTest.php b/tests/phpunit/includes/libs/MemoizedCallableTest.php new file mode 100644 index 000000000000..628cca0c6877 --- /dev/null +++ b/tests/phpunit/includes/libs/MemoizedCallableTest.php @@ -0,0 +1,142 @@ +<?php +/** + * PHPUnit tests for MemoizedCallable class. + * @covers MemoizedCallable + */ +class MemoizedCallableTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * The memoized callable should relate inputs to outputs in the same + * way as the original underlying callable. + */ + public function testReturnValuePassedThrough() { + $mock = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'reverse' ] )->getMock(); + $mock->expects( $this->any() ) + ->method( 'reverse' ) + ->will( $this->returnCallback( 'strrev' ) ); + + $memoized = new MemoizedCallable( [ $mock, 'reverse' ] ); + $this->assertEquals( 'flow', $memoized->invoke( 'wolf' ) ); + } + + /** + * Consecutive calls to the memoized callable with the same arguments + * should result in just one invocation of the underlying callable. + * + * @requires extension apcu + */ + public function testCallableMemoized() { + $observer = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'computeSomething' ] )->getMock(); + $observer->expects( $this->once() ) + ->method( 'computeSomething' ) + ->will( $this->returnValue( 'ok' ) ); + + $memoized = new ArrayBackedMemoizedCallable( [ $observer, 'computeSomething' ] ); + + // First invocation -- delegates to $observer->computeSomething() + $this->assertEquals( 'ok', $memoized->invoke() ); + + // Second invocation -- returns memoized result + $this->assertEquals( 'ok', $memoized->invoke() ); + } + + /** + * @covers MemoizedCallable::invoke + */ + public function testInvokeVariadic() { + $memoized = new MemoizedCallable( 'sprintf' ); + $this->assertEquals( + $memoized->invokeArgs( [ 'this is %s', 'correct' ] ), + $memoized->invoke( 'this is %s', 'correct' ) + ); + } + + /** + * @covers MemoizedCallable::call + */ + public function testShortcutMethod() { + $this->assertEquals( + 'this is correct', + MemoizedCallable::call( 'sprintf', [ 'this is %s', 'correct' ] ) + ); + } + + /** + * Outlier TTL values should be coerced to range 1 - 86400. + */ + public function testTTLMaxMin() { + $memoized = new MemoizedCallable( 'abs', 100000 ); + $this->assertEquals( 86400, $this->readAttribute( $memoized, 'ttl' ) ); + + $memoized = new MemoizedCallable( 'abs', -10 ); + $this->assertEquals( 1, $this->readAttribute( $memoized, 'ttl' ) ); + } + + /** + * Closure names should be distinct. + */ + public function testMemoizedClosure() { + $a = new MemoizedCallable( function () { + return 'a'; + } ); + + $b = new MemoizedCallable( function () { + return 'b'; + } ); + + $this->assertEquals( $a->invokeArgs(), 'a' ); + $this->assertEquals( $b->invokeArgs(), 'b' ); + + $this->assertNotEquals( + $this->readAttribute( $a, 'callableName' ), + $this->readAttribute( $b, 'callableName' ) + ); + + $c = new ArrayBackedMemoizedCallable( function () { + return rand(); + } ); + $this->assertEquals( $c->invokeArgs(), $c->invokeArgs(), 'memoized random' ); + } + + /** + * @expectedExceptionMessage non-scalar argument + * @expectedException InvalidArgumentException + */ + public function testNonScalarArguments() { + $memoized = new MemoizedCallable( 'gettype' ); + $memoized->invoke( new stdClass() ); + } + + /** + * @expectedExceptionMessage must be an instance of callable + * @expectedException InvalidArgumentException + */ + public function testNotCallable() { + $memoized = new MemoizedCallable( 14 ); + } +} + +/** + * A MemoizedCallable subclass that stores function return values + * in an instance property rather than APC or APCu. + */ +class ArrayBackedMemoizedCallable extends MemoizedCallable { + private $cache = []; + + protected function fetchResult( $key, &$success ) { + if ( array_key_exists( $key, $this->cache ) ) { + $success = true; + return $this->cache[$key]; + } + $success = false; + return false; + } + + protected function storeResult( $key, $result ) { + $this->cache[$key] = $result; + } +} diff --git a/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php new file mode 100644 index 000000000000..8e91e70cb0cd --- /dev/null +++ b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php @@ -0,0 +1,264 @@ +<?php + +/** + * 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 PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * Helper to verify emptiness of a cache object. + * Compare against an array so we get the cache content difference. + */ + protected function assertCacheEmpty( $cache, $msg = 'Cache should be empty' ) { + $this->assertEquals( 0, $cache->getEntriesCount(), $msg ); + } + + /** + * Helper to fill a cache object passed by reference + */ + protected 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 + */ + protected function getExpectedCache( $cacheMaxEntries, $entryToFill ) { + $expected = []; + + if ( $entryToFill === 0 ) { + // The cache is empty! + return []; + } 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"] = [ "prop-$i" => "value-$i" ]; + } + + return $expected; + } + + /** + * Highlight diff between assertEquals and assertNotSame + * @coversNothing + */ + public function testPhpUnitArrayEquality() { + $one = [ 'A' => 1, 'B' => 2 ]; + $two = [ 'B' => 2, 'A' => 1 ]; + // == + $this->assertEquals( $one, $two ); + // === + $this->assertNotSame( $one, $two ); + } + + /** + * @dataProvider provideInvalidConstructorArg + * @expectedException Wikimedia\Assert\ParameterAssertionException + * @covers ProcessCacheLRU::__construct + */ + public function testConstructorGivenInvalidValue( $maxSize ) { + new ProcessCacheLRUTestable( $maxSize ); + } + + /** + * Value which are forbidden by the constructor + */ + public static function provideInvalidConstructorArg() { + return [ + [ null ], + [ [] ], + [ new stdClass() ], + [ 0 ], + [ '5' ], + [ -1 ], + ]; + } + + /** + * @covers ProcessCacheLRU::get + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::has + */ + 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' ) ); + } + + /** + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::get + */ + 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. + * + * @covers ProcessCacheLRU::set + * @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 [ + [ 1, 0 ], + [ 1, 1 ], + // overflow + [ 1, 2 ], + // overflow + [ 5, 33 ], + ]; + } + + /** + * Create a cache with only one remaining entry then update + * the first inserted entry. Should bump it to the top. + * + * @covers ProcessCacheLRU::set + */ + 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( + [ + 'cache-key-2' => [ 'prop-2' => 'value-2' ], + 'cache-key-1' => [ 'prop-1' => 'new-value-for-1' ], + ], + $cache->getCache() + ); + } + + /** + * @covers ProcessCacheLRU::get + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::has + */ + 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). + * + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::get + */ + 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( + [ + 'cache-key-1' => [ 'prop-1' => 'value-1' ], + 'cache-key-3' => [ 'prop-3' => 'value-3' ], + 'cache-key-2' => [ 'prop-2' => 'new-value-for-2' ], + ], + $cache->getCache() + ); + $this->assertEquals( 'new-value-for-2', + $cache->get( 'cache-key-2', 'prop-2' ) + ); + } + + /** + * @covers ProcessCacheLRU::set + */ + 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( + [ + 'cache-key-2' => [ 'prop-2' => 'value-2' ], + 'cache-key-3' => [ 'prop-3' => 'value-3' ], + 'cache-key-1' => [ 'prop-1' => 'new value for 1' ], + ], + $cache->getCache() + ); + } +} + +/** + * Overrides some ProcessCacheLRU methods and properties accessibility. + */ +class ProcessCacheLRUTestable extends ProcessCacheLRU { + public function getCache() { + return $this->cache->toArray(); + } + + public function getEntriesCount() { + return count( $this->cache->toArray() ); + } +} diff --git a/tests/phpunit/includes/libs/SamplingStatsdClientTest.php b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php new file mode 100644 index 000000000000..7bd161156dcf --- /dev/null +++ b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php @@ -0,0 +1,77 @@ +<?php + +use Liuggio\StatsdClient\Entity\StatsdData; +use Liuggio\StatsdClient\Sender\SenderInterface; + +/** + * @covers SamplingStatsdClient + */ +class SamplingStatsdClientTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @dataProvider samplingDataProvider + */ + public function testSampling( $data, $sampleRate, $seed, $expectWrite ) { + $sender = $this->getMockBuilder( SenderInterface::class )->getMock(); + $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) ); + if ( $expectWrite ) { + $sender->expects( $this->once() )->method( 'write' ) + ->with( $this->anything(), $this->equalTo( $data ) ); + } else { + $sender->expects( $this->never() )->method( 'write' ); + } + if ( defined( 'MT_RAND_PHP' ) ) { + mt_srand( $seed, MT_RAND_PHP ); + } else { + mt_srand( $seed ); + } + $client = new SamplingStatsdClient( $sender ); + $client->send( $data, $sampleRate ); + } + + public function samplingDataProvider() { + $unsampled = new StatsdData(); + $unsampled->setKey( 'foo' ); + $unsampled->setValue( 1 ); + + $sampled = new StatsdData(); + $sampled->setKey( 'foo' ); + $sampled->setValue( 1 ); + $sampled->setSampleRate( '0.1' ); + + return [ + // $data, $sampleRate, $seed, $expectWrite + [ $unsampled, 1, 0 /*0.44*/, true ], + [ $sampled, 1, 0 /*0.44*/, false ], + [ $sampled, 1, 4 /*0.03*/, true ], + [ $unsampled, 0.1, 0 /*0.44*/, false ], + [ $sampled, 0.5, 0 /*0.44*/, false ], + [ $sampled, 0.5, 4 /*0.03*/, false ], + ]; + } + + public function testSetSamplingRates() { + $matching = new StatsdData(); + $matching->setKey( 'foo.bar' ); + $matching->setValue( 1 ); + + $nonMatching = new StatsdData(); + $nonMatching->setKey( 'oof.bar' ); + $nonMatching->setValue( 1 ); + + $sender = $this->getMockBuilder( SenderInterface::class )->getMock(); + $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) ); + $sender->expects( $this->once() )->method( 'write' )->with( $this->anything(), + $this->equalTo( $nonMatching ) ); + + $client = new SamplingStatsdClient( $sender ); + $client->setSamplingRates( [ 'foo.*' => 0.2 ] ); + + mt_srand( 0 ); // next random is 0.44 + $client->send( $matching ); + mt_srand( 0 ); + $client->send( $nonMatching ); + } +} diff --git a/tests/phpunit/includes/libs/StaticArrayWriterTest.php b/tests/phpunit/includes/libs/StaticArrayWriterTest.php new file mode 100644 index 000000000000..4bd845d0b992 --- /dev/null +++ b/tests/phpunit/includes/libs/StaticArrayWriterTest.php @@ -0,0 +1,58 @@ +<?php +/** + * Copyright (C) 2018 Kunal Mehta <legoktm@member.fsf.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. + * + */ + +use Wikimedia\StaticArrayWriter; + +/** + * @covers \Wikimedia\StaticArrayWriter + */ +class StaticArrayWriterTest extends PHPUnit\Framework\TestCase { + public function testCreate() { + $data = [ + 'foo' => 'bar', + 'baz' => 'rawr', + "they're" => '"quoted properly"', + 'nested' => [ 'elements', 'work' ], + 'and' => [ 'these' => 'do too' ], + ]; + $writer = new StaticArrayWriter(); + $actual = $writer->create( $data, "Header\nWith\nNewlines" ); + $expected = <<<PHP +<?php +// Header +// With +// Newlines +return [ + 'foo' => 'bar', + 'baz' => 'rawr', + 'they\'re' => '"quoted properly"', + 'nested' => [ + 0 => 'elements', + 1 => 'work', + ], + 'and' => [ + 'these' => 'do too', + ], +]; + +PHP; + $this->assertSame( $expected, $actual ); + } +} diff --git a/tests/phpunit/includes/libs/StringUtilsTest.php b/tests/phpunit/includes/libs/StringUtilsTest.php new file mode 100644 index 000000000000..fcfa53e22df7 --- /dev/null +++ b/tests/phpunit/includes/libs/StringUtilsTest.php @@ -0,0 +1,128 @@ +<?php + +class StringUtilsTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers StringUtils::isUtf8 + * @dataProvider provideStringsForIsUtf8Check + */ + public function testIsUtf8( $expected, $string ) { + $this->assertEquals( $expected, StringUtils::isUtf8( $string ), + 'Testing string "' . $this->escaped( $string ) . '"' ); + } + + /** + * 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 [ + 'some ASCII' => [ $PASS, 'Some ASCII' ], + 'euro sign' => [ $PASS, "Euro sign €" ], + + 'first possible sequence 1 byte' => [ $PASS, "\x00" ], + 'first possible sequence 2 bytes' => [ $PASS, "\xc2\x80" ], + 'first possible sequence 3 bytes' => [ $PASS, "\xe0\xa0\x80" ], + 'first possible sequence 4 bytes' => [ $PASS, "\xf0\x90\x80\x80" ], + 'first possible sequence 5 bytes' => [ $FAIL, "\xf8\x88\x80\x80\x80" ], + 'first possible sequence 6 bytes' => [ $FAIL, "\xfc\x84\x80\x80\x80\x80" ], + + 'last possible sequence 1 byte' => [ $PASS, "\x7f" ], + 'last possible sequence 2 bytes' => [ $PASS, "\xdf\xbf" ], + 'last possible sequence 3 bytes' => [ $PASS, "\xef\xbf\xbf" ], + 'last possible sequence 4 bytes (U+1FFFFF)' => [ $FAIL, "\xf7\xbf\xbf\xbf" ], + 'last possible sequence 5 bytes' => [ $FAIL, "\xfb\xbf\xbf\xbf\xbf" ], + 'last possible sequence 6 bytes' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf\xbf" ], + + 'boundary 1' => [ $PASS, "\xed\x9f\xbf" ], + 'boundary 2' => [ $PASS, "\xee\x80\x80" ], + 'boundary 3' => [ $PASS, "\xef\xbf\xbd" ], + 'boundary 4' => [ $PASS, "\xf2\x80\x80\x80" ], + 'boundary 5 (U+FFFFF)' => [ $PASS, "\xf3\xbf\xbf\xbf" ], + 'boundary 6 (U+100000)' => [ $PASS, "\xf4\x80\x80\x80" ], + 'boundary 7 (U+10FFFF)' => [ $PASS, "\xf4\x8f\xbf\xbf" ], + 'boundary 8 (U+110000)' => [ $FAIL, "\xf4\x90\x80\x80" ], + + 'malformed 1' => [ $FAIL, "\x80" ], + 'malformed 2' => [ $FAIL, "\xbf" ], + 'malformed 3' => [ $FAIL, "\x80\xbf" ], + 'malformed 4' => [ $FAIL, "\x80\xbf\x80" ], + 'malformed 5' => [ $FAIL, "\x80\xbf\x80\xbf" ], + 'malformed 6' => [ $FAIL, "\x80\xbf\x80\xbf\x80" ], + 'malformed 7' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf" ], + 'malformed 8' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf\x80" ], + + 'last byte missing 1' => [ $FAIL, "\xc0" ], + 'last byte missing 2' => [ $FAIL, "\xe0\x80" ], + 'last byte missing 3' => [ $FAIL, "\xf0\x80\x80" ], + 'last byte missing 4' => [ $FAIL, "\xf8\x80\x80\x80" ], + 'last byte missing 5' => [ $FAIL, "\xfc\x80\x80\x80\x80" ], + 'last byte missing 6' => [ $FAIL, "\xdf" ], + 'last byte missing 7' => [ $FAIL, "\xef\xbf" ], + 'last byte missing 8' => [ $FAIL, "\xf7\xbf\xbf" ], + 'last byte missing 9' => [ $FAIL, "\xfb\xbf\xbf\xbf" ], + 'last byte missing 10' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf" ], + + 'extra continuation byte 1' => [ $FAIL, "e\xaf" ], + 'extra continuation byte 2' => [ $FAIL, "\xc3\x89\xaf" ], + 'extra continuation byte 3' => [ $FAIL, "\xef\xbc\xa5\xaf" ], + 'extra continuation byte 4' => [ $FAIL, "\xf0\x9d\x99\xb4\xaf" ], + + 'impossible bytes 1' => [ $FAIL, "\xfe" ], + 'impossible bytes 2' => [ $FAIL, "\xff" ], + 'impossible bytes 3' => [ $FAIL, "\xfe\xfe\xff\xff" ], + + 'overlong sequences 1' => [ $FAIL, "\xc0\xaf" ], + 'overlong sequences 2' => [ $FAIL, "\xc1\xaf" ], + 'overlong sequences 3' => [ $FAIL, "\xe0\x80\xaf" ], + 'overlong sequences 4' => [ $FAIL, "\xf0\x80\x80\xaf" ], + 'overlong sequences 5' => [ $FAIL, "\xf8\x80\x80\x80\xaf" ], + 'overlong sequences 6' => [ $FAIL, "\xfc\x80\x80\x80\x80\xaf" ], + + 'maximum overlong sequences 1' => [ $FAIL, "\xc1\xbf" ], + 'maximum overlong sequences 2' => [ $FAIL, "\xe0\x9f\xbf" ], + 'maximum overlong sequences 3' => [ $FAIL, "\xf0\x8f\xbf\xbf" ], + 'maximum overlong sequences 4' => [ $FAIL, "\xf8\x87\xbf\xbf" ], + 'maximum overlong sequences 5' => [ $FAIL, "\xfc\x83\xbf\xbf\xbf\xbf" ], + + 'surrogates 1 (U+D799)' => [ $PASS, "\xed\x9f\xbf" ], + 'surrogates 2 (U+E000)' => [ $PASS, "\xee\x80\x80" ], + 'surrogates 3 (U+D800)' => [ $FAIL, "\xed\xa0\x80" ], + 'surrogates 4 (U+DBFF)' => [ $FAIL, "\xed\xaf\xbf" ], + 'surrogates 5 (U+DC00)' => [ $FAIL, "\xed\xb0\x80" ], + 'surrogates 6 (U+DFFF)' => [ $FAIL, "\xed\xbf\xbf" ], + 'surrogates 7 (U+D800 U+DC00)' => [ $FAIL, "\xed\xa0\x80\xed\xb0\x80" ], + + 'noncharacters 1' => [ $PASS, "\xef\xbf\xbe" ], + 'noncharacters 2' => [ $PASS, "\xef\xbf\xbf" ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/TimingTest.php b/tests/phpunit/includes/libs/TimingTest.php new file mode 100644 index 000000000000..581a5186265f --- /dev/null +++ b/tests/phpunit/includes/libs/TimingTest.php @@ -0,0 +1,115 @@ +<?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 + * @author Ori Livneh <ori@wikimedia.org> + */ + +class TimingTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers Timing::clearMarks + * @covers Timing::getEntries + */ + public function testClearMarks() { + $timing = new Timing; + $this->assertCount( 1, $timing->getEntries() ); + + $timing->mark( 'a' ); + $timing->mark( 'b' ); + $this->assertCount( 3, $timing->getEntries() ); + + $timing->clearMarks( 'a' ); + $this->assertNull( $timing->getEntryByName( 'a' ) ); + $this->assertNotNull( $timing->getEntryByName( 'b' ) ); + + $timing->clearMarks(); + $this->assertCount( 1, $timing->getEntries() ); + } + + /** + * @covers Timing::mark + * @covers Timing::getEntryByName + */ + public function testMark() { + $timing = new Timing; + $timing->mark( 'a' ); + + $entry = $timing->getEntryByName( 'a' ); + $this->assertEquals( 'a', $entry['name'] ); + $this->assertEquals( 'mark', $entry['entryType'] ); + $this->assertArrayHasKey( 'startTime', $entry ); + $this->assertEquals( 0, $entry['duration'] ); + + usleep( 100 ); + $timing->mark( 'a' ); + $newEntry = $timing->getEntryByName( 'a' ); + $this->assertGreaterThan( $entry['startTime'], $newEntry['startTime'] ); + } + + /** + * @covers Timing::measure + */ + public function testMeasure() { + $timing = new Timing; + + $timing->mark( 'a' ); + usleep( 100 ); + $timing->mark( 'b' ); + + $a = $timing->getEntryByName( 'a' ); + $b = $timing->getEntryByName( 'b' ); + + $timing->measure( 'a_to_b', 'a', 'b' ); + + $entry = $timing->getEntryByName( 'a_to_b' ); + $this->assertEquals( 'a_to_b', $entry['name'] ); + $this->assertEquals( 'measure', $entry['entryType'] ); + $this->assertEquals( $a['startTime'], $entry['startTime'] ); + $this->assertEquals( $b['startTime'] - $a['startTime'], $entry['duration'] ); + } + + /** + * @covers Timing::getEntriesByType + */ + public function testGetEntriesByType() { + $timing = new Timing; + + $timing->mark( 'mark_a' ); + usleep( 100 ); + $timing->mark( 'mark_b' ); + usleep( 100 ); + $timing->mark( 'mark_c' ); + + $timing->measure( 'measure_a', 'mark_a', 'mark_b' ); + $timing->measure( 'measure_b', 'mark_b', 'mark_c' ); + + $marks = array_map( function ( $entry ) { + return $entry['name']; + }, $timing->getEntriesByType( 'mark' ) ); + + $this->assertEquals( [ 'requestStart', 'mark_a', 'mark_b', 'mark_c' ], $marks ); + + $measures = array_map( function ( $entry ) { + return $entry['name']; + }, $timing->getEntriesByType( 'measure' ) ); + + $this->assertEquals( [ 'measure_a', 'measure_b' ], $measures ); + } +} diff --git a/tests/phpunit/includes/libs/XhprofDataTest.php b/tests/phpunit/includes/libs/XhprofDataTest.php new file mode 100644 index 000000000000..3e9379456d38 --- /dev/null +++ b/tests/phpunit/includes/libs/XhprofDataTest.php @@ -0,0 +1,274 @@ +<?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 + */ + +/** + * @copyright © 2014 Wikimedia Foundation and contributors + * @since 1.25 + */ +class XhprofDataTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers XhprofData::splitKey + * @dataProvider provideSplitKey + */ + public function testSplitKey( $key, $expect ) { + $this->assertSame( $expect, XhprofData::splitKey( $key ) ); + } + + public function provideSplitKey() { + return [ + [ 'main()', [ null, 'main()' ] ], + [ 'foo==>bar', [ 'foo', 'bar' ] ], + [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ], + [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ], + [ '==>bar', [ '', 'bar' ] ], + [ '', [ null, '' ] ], + ]; + } + + /** + * @covers XhprofData::pruneData + */ + public function testInclude() { + $xhprofData = $this->getXhprofDataFixture( [ + 'include' => [ 'main()' ], + ] ); + $raw = $xhprofData->getRawData(); + $this->assertArrayHasKey( 'main()', $raw ); + $this->assertArrayHasKey( 'main()==>foo', $raw ); + $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw ); + $this->assertSame( 3, count( $raw ) ); + } + + /** + * Validate the structure of data returned by + * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getInclusiveMetrics + */ + public function testInclusiveMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getInclusiveMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric ); + + foreach ( $metricStruct as $key => $type ) { + if ( $type === 'array' ) { + $this->assertArrayStructure( $statStruct, $metric[$key] ); + if ( $name === 'main()' ) { + $this->assertEquals( 100, $metric[$key]['percent'] ); + } + } + } + } + } + + /** + * Validate the structure of data returned by + * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getCompleteMetrics + */ + public function testCompleteMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + 'calls' => 'array', + 'subcalls' => 'array', + ]; + $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + 'exclusive' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getCompleteMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric, $name ); + + foreach ( $metricStruct as $key => $type ) { + if ( in_array( $key, $statsMetrics ) ) { + $this->assertArrayStructure( + $statStruct, $metric[$key], $key + ); + $this->assertLessThanOrEqual( + $metric[$key]['total'], $metric[$key]['exclusive'] + ); + } + } + } + } + + /** + * @covers XhprofData::getCallers + * @covers XhprofData::getCallees + */ + public function testEdges() { + $xhprofData = $this->getXhprofDataFixture(); + $this->assertSame( [], $xhprofData->getCallers( 'main()' ) ); + $this->assertSame( [ 'foo', 'xhprof_disable' ], + $xhprofData->getCallees( 'main()' ) + ); + $this->assertSame( [ 'main()' ], + $xhprofData->getCallers( 'foo' ) + ); + $this->assertSame( [], $xhprofData->getCallees( 'strlen' ) ); + } + + /** + * @covers XhprofData::getCriticalPath + */ + public function testCriticalPath() { + $xhprofData = $this->getXhprofDataFixture(); + $path = $xhprofData->getCriticalPath(); + + $last = null; + foreach ( $path as $key => $value ) { + list( $func, $call ) = XhprofData::splitKey( $key ); + $this->assertSame( $last, $func ); + $last = $call; + } + $this->assertSame( $last, 'bar@1' ); + } + + /** + * Get an Xhprof instance that has been primed with a set of known testing + * data. Tests for the Xhprof class should laregly be concerned with + * evaluating the manipulations of the data collected by xhprof rather + * than the data collection process itself. + * + * The returned Xhprof instance primed will be with a data set created by + * running this trivial program using the PECL xhprof implementation: + * @code + * function bar( $x ) { + * if ( $x > 0 ) { + * bar($x - 1); + * } + * } + * function foo() { + * for ( $idx = 0; $idx < 2; $idx++ ) { + * bar( $idx ); + * $x = strlen( 'abc' ); + * } + * } + * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY ); + * foo(); + * $x = xhprof_disable(); + * var_export( $x ); + * @endcode + * + * @return Xhprof + */ + protected function getXhprofDataFixture( array $opts = [] ) { + return new XhprofData( [ + 'foo==>bar' => [ + 'ct' => 2, + 'wt' => 57, + 'cpu' => 92, + 'mu' => 1896, + 'pmu' => 0, + ], + 'foo==>strlen' => [ + 'ct' => 2, + 'wt' => 21, + 'cpu' => 141, + 'mu' => 752, + 'pmu' => 0, + ], + 'bar==>bar@1' => [ + 'ct' => 1, + 'wt' => 18, + 'cpu' => 19, + 'mu' => 752, + 'pmu' => 0, + ], + 'main()==>foo' => [ + 'ct' => 1, + 'wt' => 304, + 'cpu' => 307, + 'mu' => 4008, + 'pmu' => 0, + ], + 'main()==>xhprof_disable' => [ + 'ct' => 1, + 'wt' => 8, + 'cpu' => 10, + 'mu' => 768, + 'pmu' => 392, + ], + 'main()' => [ + 'ct' => 1, + 'wt' => 353, + 'cpu' => 351, + 'mu' => 6112, + 'pmu' => 1424, + ], + ], $opts ); + } + + /** + * Assert that the given array has the described structure. + * + * @param array $struct Array of key => type mappings + * @param array $actual Array to check + * @param string $label + */ + protected function assertArrayStructure( $struct, $actual, $label = null ) { + $this->assertInternalType( 'array', $actual, $label ); + $this->assertCount( count( $struct ), $actual, $label ); + foreach ( $struct as $key => $type ) { + $this->assertArrayHasKey( $key, $actual ); + $this->assertInternalType( $type, $actual[$key] ); + } + } +} diff --git a/tests/phpunit/includes/libs/XhprofTest.php b/tests/phpunit/includes/libs/XhprofTest.php new file mode 100644 index 000000000000..ccad4a43d6b8 --- /dev/null +++ b/tests/phpunit/includes/libs/XhprofTest.php @@ -0,0 +1,113 @@ +<?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 + */ + +class XhprofTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * Trying to enable Xhprof when it is already enabled causes an exception + * to be thrown. + * + * @expectedException Exception + * @expectedExceptionMessage already enabled + * @covers Xhprof::enable + */ + public function testEnable() { + $xhprof = new ReflectionClass( Xhprof::class ); + $enabled = $xhprof->getProperty( 'enabled' ); + $enabled->setAccessible( true ); + $enabled->setValue( true ); + $xhprof->getMethod( 'enable' )->invoke( null ); + } + + /** + * callAny() calls the first function of the list. + * + * @covers Xhprof::callAny + * @dataProvider provideCallAny + */ + public function testCallAny( array $functions, array $args, $expectedResult ) { + $xhprof = new ReflectionClass( Xhprof::class ); + $callAny = $xhprof->getMethod( 'callAny' ); + $callAny->setAccessible( true ); + + $this->assertEquals( $expectedResult, + $callAny->invoke( null, $functions, $args ) ); + } + + /** + * Data provider for testCallAny(). + */ + public function provideCallAny() { + return [ + [ + [ 'wfTestCallAny_func1', 'wfTestCallAny_func2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + 12 + ], + [ + [ 'wfTestCallAny_nosuchfunc1', 'wfTestCallAny_func2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + 7 + ], + [ + [ 'wfTestCallAny_nosuchfunc1', 'wfTestCallAny_nosuchfunc2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + -1 + ] + + ]; + } + + /** + * callAny() throws an exception when all functions are unavailable. + * + * @expectedException Exception + * @expectedExceptionMessage Neither xhprof nor tideways are installed + * @covers Xhprof::callAny + */ + public function testCallAnyNoneAvailable() { + $xhprof = new ReflectionClass( Xhprof::class ); + $callAny = $xhprof->getMethod( 'callAny' ); + $callAny->setAccessible( true ); + + $callAny->invoke( $xhprof, [ + 'wfTestCallAny_nosuchfunc1', + 'wfTestCallAny_nosuchfunc2', + 'wfTestCallAny_nosuchfunc3' + ] ); + } +} + +/** Test function #1 for XhprofTest::testCallAny */ +function wfTestCallAny_func1( $a, $b ) { + return $a * $b; +} + +/** Test function #2 for XhprofTest::testCallAny */ +function wfTestCallAny_func2( $a, $b ) { + return $a + $b; +} + +/** Test function #3 for XhprofTest::testCallAny */ +function wfTestCallAny_func3( $a, $b ) { + return $a - $b; +} diff --git a/tests/phpunit/includes/libs/XmlTypeCheckTest.php b/tests/phpunit/includes/libs/XmlTypeCheckTest.php new file mode 100644 index 000000000000..8616b4192299 --- /dev/null +++ b/tests/phpunit/includes/libs/XmlTypeCheckTest.php @@ -0,0 +1,79 @@ +<?php +/** + * PHPUnit tests for XMLTypeCheck. + * @author physikerwelt + * @group Xml + * @covers XMLTypeCheck + */ +class XmlTypeCheckTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + const WELL_FORMED_XML = "<root><child /></root>"; + const MAL_FORMED_XML = "<root><child /></error>"; + // phpcs:ignore Generic.Files.LineLength + 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 ); + } + + /** + * Verify we check for recursive entity DOS + * + * (If the DOS isn't properly handled, the test runner will probably go OOM...) + */ + public function testRecursiveEntity() { + $xml = <<<'XML' +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE foo [ + <!ENTITY test "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"> + <!ENTITY a "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;"> + <!ENTITY b "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;"> + <!ENTITY c "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;"> + <!ENTITY d "&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;"> + <!ENTITY e "&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;"> + <!ENTITY f "&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;"> + <!ENTITY g "-00000000000000000000000000000000000000000000000000000000000000000000000-"> +]> +<foo> +<bar>&test;</bar> +</foo> +XML; + $check = XmlTypeCheck::newFromString( $xml ); + $this->assertFalse( $check->wellFormed ); + } + + /** + * @covers XMLTypeCheck::processingInstructionHandler + */ + public function testProcessingInstructionHandler() { + $called = false; + $testXML = new XmlTypeCheck( + self::XML_WITH_PIH, + null, + false, + [ + 'processing_instruction_handler' => function () use ( &$called ) { + $called = true; + } + ] + ); + $this->assertTrue( $called ); + } + +} diff --git a/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php b/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php new file mode 100644 index 000000000000..58e617ca82e6 --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php @@ -0,0 +1,498 @@ +<?php + +class ComposerInstalledTest extends PHPUnit\Framework\TestCase { + + private $installed; + + public function setUp() { + parent::setUp(); + $this->installed = __DIR__ . "/../../../data/composer/installed.json"; + } + + /** + * @covers ComposerInstalled::__construct + * @covers ComposerInstalled::getInstalledDependencies + */ + public function testGetInstalledDependencies() { + $installed = new ComposerInstalled( $this->installed ); + $this->assertEquals( [ + 'leafo/lessphp' => [ + 'version' => '0.5.0', + 'type' => 'library', + 'licenses' => [ 'MIT', 'GPL-3.0-only' ], + 'authors' => [ + [ + 'name' => 'Leaf Corcoran', + 'email' => 'leafot@gmail.com', + 'homepage' => 'http://leafo.net', + ], + ], + 'description' => 'lessphp is a compiler for LESS written in PHP.', + ], + 'psr/log' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'PHP-FIG', + 'homepage' => 'http://www.php-fig.org/', + ], + ], + 'description' => 'Common interface for logging libraries', + ], + 'cssjanus/cssjanus' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'Apache-2.0' ], + 'authors' => [ + ], + 'description' => 'Convert CSS stylesheets between left-to-right ' . + 'and right-to-left.', + ], + 'cdb/cdb' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'GPLv2' ], + 'authors' => [ + [ + 'name' => 'Tim Starling', + 'email' => 'tstarling@wikimedia.org', + ], + [ + 'name' => 'Chad Horohoe', + 'email' => 'chad@wikimedia.org', + ], + ], + 'description' => 'Constant Database (CDB) wrapper library for PHP. ' . + 'Provides pure-PHP fallback when dba_* functions are absent.', + ], + 'sebastian/version' => [ + 'version' => '2.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Library that helps with managing the version ' . + 'number of Git-hosted PHP projects', + ], + 'sebastian/resource-operations' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides a list of PHP built-in functions that ' . + 'operate on resources', + ], + 'sebastian/recursion-context' => [ + 'version' => '3.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + [ + 'name' => 'Adam Harvey', + 'email' => 'aharvey@php.net', + ], + ], + 'description' => 'Provides functionality to recursively process PHP ' . + 'variables', + ], + 'sebastian/object-reflector' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Allows reflection of object attributes, including ' . + 'inherited and non-public ones', + ], + 'sebastian/object-enumerator' => [ + 'version' => '3.0.3', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Traverses array structures and object graphs ' . + 'to enumerate all referenced objects', + ], + 'sebastian/global-state' => [ + 'version' => '2.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Snapshotting of global state', + ], + 'sebastian/exporter' => [ + 'version' => '3.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Volker Dusch', + 'email' => 'github@wallbash.com', + ], + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@2bepublished.at', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + [ + 'name' => 'Adam Harvey', + 'email' => 'aharvey@php.net', + ], + ], + 'description' => 'Provides the functionality to export PHP ' . + 'variables for visualization', + ], + 'sebastian/environment' => [ + 'version' => '3.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides functionality to handle HHVM/PHP ' . + 'environments', + ], + 'sebastian/diff' => [ + 'version' => '2.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Kore Nordmann', + 'email' => 'mail@kore-nordmann.de', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Diff implementation', + ], + 'sebastian/comparator' => [ + 'version' => '2.1.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Volker Dusch', + 'email' => 'github@wallbash.com', + ], + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@2bepublished.at', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides the functionality to compare PHP ' . + 'values for equality', + ], + 'doctrine/instantiator' => [ + 'version' => '1.1.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Marco Pivetta', + 'email' => 'ocramius@gmail.com', + 'homepage' => 'http://ocramius.github.com/', + ], + ], + 'description' => 'A small, lightweight utility to instantiate ' . + 'objects in PHP without invoking their constructors', + ], + 'phpunit/php-text-template' => [ + 'version' => '1.2.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Simple template engine.', + ], + 'phpunit/phpunit-mock-objects' => [ + 'version' => '5.0.6', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Mock Object library for PHPUnit', + ], + 'phpunit/php-timer' => [ + 'version' => '1.0.9', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sb@sebastian-bergmann.de', + 'role' => 'lead', + ], + ], + 'description' => 'Utility class for timing', + ], + 'phpunit/php-file-iterator' => [ + 'version' => '1.4.5', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sb@sebastian-bergmann.de', + 'role' => 'lead', + ], + ], + 'description' => 'FilterIterator implementation that filters ' . + 'files based on a list of suffixes.', + ], + 'theseer/tokenizer' => [ + 'version' => '1.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + ], + 'description' => 'A small library for converting tokenized PHP ' . + 'source code into XML and potentially other formats', + ], + 'sebastian/code-unit-reverse-lookup' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Looks up which function or method a line of ' . + 'code belongs to', + ], + 'phpunit/php-token-stream' => [ + 'version' => '2.0.2', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Wrapper around PHP\'s tokenizer extension.', + ], + 'phpunit/php-code-coverage' => [ + 'version' => '5.3.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Library that provides collection, processing, ' . + 'and rendering functionality for PHP code coverage information.', + ], + 'webmozart/assert' => [ + 'version' => '1.2.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@gmail.com', + ], + ], + 'description' => 'Assertions to validate method input/output with ' . + 'nice error messages.', + ], + 'phpdocumentor/reflection-common' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Jaap van Otterdijk', + 'email' => 'opensource@ijaap.nl', + ], + ], + 'description' => 'Common reflection classes used by phpdocumentor to ' . + 'reflect the code structure', + ], + 'phpdocumentor/type-resolver' => [ + 'version' => '0.4.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Mike van Riel', + 'email' => 'me@mikevanriel.com', + ], + ], + 'description' => '', + ], + 'phpdocumentor/reflection-docblock' => [ + 'version' => '4.2.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Mike van Riel', + 'email' => 'me@mikevanriel.com', + ], + ], + 'description' => 'With this component, a library can provide support for ' . + 'annotations via DocBlocks or otherwise retrieve information that ' . + 'is embedded in a DocBlock.', + ], + 'phpspec/prophecy' => [ + 'version' => '1.7.3', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Konstantin Kudryashov', + 'email' => 'ever.zet@gmail.com', + 'homepage' => 'http://everzet.com', + ], + [ + 'name' => 'Marcello Duarte', + 'email' => 'marcello.duarte@gmail.com', + ], + ], + 'description' => 'Highly opinionated mocking framework for PHP 5.3+', + ], + 'phar-io/version' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Heuer', + 'email' => 'sebastian@phpeople.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'Developer', + ], + ], + 'description' => 'Library for handling version information and constraints', + ], + 'phar-io/manifest' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Heuer', + 'email' => 'sebastian@phpeople.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'Developer', + ], + ], + 'description' => 'Component for reading phar.io manifest ' . + 'information from a PHP Archive (PHAR)', + ], + 'myclabs/deep-copy' => [ + 'version' => '1.7.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + ], + 'description' => 'Create deep copies (clones) of your objects', + ], + 'phpunit/phpunit' => [ + 'version' => '6.5.5', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'The PHP Unit Testing framework.', + ], + ], $installed->getInstalledDependencies() ); + } +} diff --git a/tests/phpunit/includes/libs/composer/ComposerJsonTest.php b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php new file mode 100644 index 000000000000..720fa6e8fd1a --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php @@ -0,0 +1,41 @@ +<?php + +class ComposerJsonTest extends PHPUnit\Framework\TestCase { + + private $json, $json2; + + public function setUp() { + parent::setUp(); + $this->json = __DIR__ . "/../../../data/composer/composer.json"; + $this->json2 = __DIR__ . "/../../../data/composer/new-composer.json"; + } + + /** + * @covers ComposerJson::__construct + * @covers ComposerJson::getRequiredDependencies + */ + public function testGetRequiredDependencies() { + $json = new ComposerJson( $this->json ); + $this->assertEquals( [ + 'cdb/cdb' => '1.0.0', + 'cssjanus/cssjanus' => '1.1.1', + 'leafo/lessphp' => '0.5.0', + 'psr/log' => '1.0.0', + ], $json->getRequiredDependencies() ); + } + + public static function provideNormalizeVersion() { + return [ + [ 'v1.0.0', '1.0.0' ], + [ '0.0.5', '0.0.5' ], + ]; + } + + /** + * @dataProvider provideNormalizeVersion + * @covers ComposerJson::normalizeVersion + */ + public function testNormalizeVersion( $input, $expected ) { + $this->assertEquals( $expected, ComposerJson::normalizeVersion( $input ) ); + } +} diff --git a/tests/phpunit/includes/libs/composer/ComposerLockTest.php b/tests/phpunit/includes/libs/composer/ComposerLockTest.php new file mode 100644 index 000000000000..f5fcdbe01819 --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerLockTest.php @@ -0,0 +1,120 @@ +<?php + +class ComposerLockTest extends PHPUnit\Framework\TestCase { + + private $lock; + + public function setUp() { + parent::setUp(); + $this->lock = __DIR__ . "/../../../data/composer/composer.lock"; + } + + /** + * @covers ComposerLock::__construct + * @covers ComposerLock::getInstalledDependencies + */ + public function testGetInstalledDependencies() { + $lock = new ComposerLock( $this->lock ); + $this->assertEquals( [ + 'wikimedia/cdb' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'GPL-2.0-only' ], + 'authors' => [ + [ + 'name' => 'Tim Starling', + 'email' => 'tstarling@wikimedia.org', + ], + [ + 'name' => 'Chad Horohoe', + 'email' => 'chad@wikimedia.org', + ], + ], + 'description' => 'Constant Database (CDB) wrapper library for PHP. ' . + 'Provides pure-PHP fallback when dba_* functions are absent.', + ], + 'cssjanus/cssjanus' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'Apache-2.0' ], + 'authors' => [], + 'description' => 'Convert CSS stylesheets between left-to-right and right-to-left.', + ], + 'leafo/lessphp' => [ + 'version' => '0.5.0', + 'type' => 'library', + 'licenses' => [ 'MIT', 'GPL-3.0-only' ], + 'authors' => [ + [ + 'name' => 'Leaf Corcoran', + 'email' => 'leafot@gmail.com', + 'homepage' => 'http://leafo.net', + ], + ], + 'description' => 'lessphp is a compiler for LESS written in PHP.', + ], + 'psr/log' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'PHP-FIG', + 'homepage' => 'http://www.php-fig.org/', + ], + ], + 'description' => 'Common interface for logging libraries', + ], + 'oojs/oojs-ui' => [ + 'version' => '0.6.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [], + 'description' => '', + ], + 'composer/installers' => [ + 'version' => '1.0.19', + 'type' => 'composer-installer', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Kyle Robinson Young', + 'email' => 'kyle@dontkry.com', + 'homepage' => 'https://github.com/shama', + ], + ], + 'description' => 'A multi-framework Composer library installer', + ], + 'mediawiki/translate' => [ + 'version' => '2014.12', + 'type' => 'mediawiki-extension', + 'licenses' => [ 'GPL-2.0-or-later' ], + 'authors' => [ + [ + 'name' => 'Niklas Laxström', + 'email' => 'niklas.laxstrom@gmail.com', + 'role' => 'Lead nitpicker', + ], + [ + 'name' => 'Siebrand Mazeland', + 'email' => 's.mazeland@xs4all.nl', + 'role' => 'Developer', + ], + ], + 'description' => 'The only standard solution to translate any kind ' . + 'of text with an avant-garde web interface within MediaWiki, ' . + 'including your documentation and software', + ], + 'mediawiki/universal-language-selector' => [ + 'version' => '2014.12', + 'type' => 'mediawiki-extension', + 'licenses' => [ 'GPL-2.0-or-later', 'MIT' ], + 'authors' => [], + 'description' => 'The primary aim is to allow users to select a language ' . + 'and configure its support in an easy way. ' . + 'Main features are language selection, input methods and web fonts.', + ], + ], $lock->getInstalledDependencies() ); + } + +} diff --git a/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php b/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php new file mode 100644 index 000000000000..02eac1188782 --- /dev/null +++ b/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php @@ -0,0 +1,150 @@ +<?php + +use Wikimedia\Http\HttpAcceptNegotiator; + +/** + * @covers Wikimedia\Http\HttpAcceptNegotiator + * + * @author Daniel Kinzler + */ +class HttpAcceptNegotiatorTest extends \PHPUnit\Framework\TestCase { + + public function provideGetFirstSupportedValue() { + return [ + [ // #0: empty + [], // supported + [], // accepted + null, // default + null, // expected + ], + [ // #1: simple + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy', 'text/bar' ], // accepted + null, // default + 'text/BAR', // expected + ], + [ // #2: default + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy', 'text/xoo' ], // accepted + 'X', // default + 'X', // expected + ], + [ // #3: preference + [ 'text/foo', 'text/bar', 'application/zuul' ], // supported + [ 'text/xoo', 'text/BAR', 'text/foo' ], // accepted + null, // default + 'text/bar', // expected + ], + [ // #4: * wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo', '*' ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #5: */* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo', '*/*' ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #6: text/* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'application/*', 'text/foo' ], // accepted + null, // default + 'application/zuul', // expected + ], + ]; + } + + /** + * @dataProvider provideGetFirstSupportedValue + */ + public function testGetFirstSupportedValue( $supported, $accepted, $default, $expected ) { + $negotiator = new HttpAcceptNegotiator( $supported ); + $actual = $negotiator->getFirstSupportedValue( $accepted, $default ); + + $this->assertEquals( $expected, $actual ); + } + + public function provideGetBestSupportedKey() { + return [ + [ // #0: empty + [], // supported + [], // accepted + null, // default + null, // expected + ], + [ // #1: simple + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy' => 1, 'text/bar' => 0.5 ], // accepted + null, // default + 'text/BAR', // expected + ], + [ // #2: default + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy' => 1, 'text/xoo' => 0.5 ], // accepted + 'X', // default + 'X', // expected + ], + [ // #3: weighted + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0.3, 'text/BAR' => 0.8, 'application/zuul' => 0.5 ], // accepted + null, // default + 'text/BAR', // expected + ], + [ // #4: zero weight + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0, 'text/xoo' => 1 ], // accepted + null, // default + null, // expected + ], + [ // #5: * wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo' => 0.5, '*' => 0.1 ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #6: */* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo' => 0.5, '*/*' => 0.1 ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #7: text/* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0.3, 'application/*' => 0.8 ], // accepted + null, // default + 'application/zuul', // expected + ], + [ // #8: Test specific format preferred over wildcard (T133314) + [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported + [ '*/*' => 1, 'text/html' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + [ // #9: Test specific format preferred over range (T133314) + [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported + [ 'text/*' => 1, 'text/html' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + [ // #10: Test range preferred over wildcard (T133314) + [ 'application/rdf+xml', 'text/html' ], // supported + [ '*/*' => 1, 'text/*' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + ]; + } + + /** + * @dataProvider provideGetBestSupportedKey + */ + public function testGetBestSupportedKey( $supported, $accepted, $default, $expected ) { + $negotiator = new HttpAcceptNegotiator( $supported ); + $actual = $negotiator->getBestSupportedKey( $accepted, $default ); + + $this->assertEquals( $expected, $actual ); + } + +} diff --git a/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php new file mode 100644 index 000000000000..e4b47b46d57f --- /dev/null +++ b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php @@ -0,0 +1,56 @@ +<?php + +use Wikimedia\Http\HttpAcceptParser; + +/** + * @covers Wikimedia\Http\HttpAcceptParser + * + * @author Daniel Kinzler + */ +class HttpAcceptParserTest extends \PHPUnit\Framework\TestCase { + + public function provideParseWeights() { + return [ + [ // #0 + '', + [] + ], + [ // #1 + 'Foo/Bar', + [ 'foo/bar' => 1 ] + ], + [ // #2 + 'Accept: text/plain', + [ 'text/plain' => 1 ] + ], + [ // #3 + 'Accept: application/vnd.php.serialized, application/rdf+xml', + [ 'application/vnd.php.serialized' => 1, 'application/rdf+xml' => 1 ] + ], + [ // #4 + 'foo; q=0.2, xoo; q=0,text/n3', + [ 'text/n3' => 1, 'foo' => 0.2 ] + ], + [ // #5 + '*; q=0.2, */*; q=0.1,text/*', + [ 'text/*' => 1, '*' => 0.2, '*/*' => 0.1 ] + ], + // TODO: nicely ignore additional type paramerters + //[ // #6 + // 'Foo; q=0.2, Xoo; level=3, Bar; charset=xyz; q=0.4', + // [ 'xoo' => 1, 'bar' => 0.4, 'foo' => 0.1 ] + //], + ]; + } + + /** + * @dataProvider provideParseWeights + */ + public function testParseWeights( $header, $expected ) { + $parser = new HttpAcceptParser(); + $actual = $parser->parseWeights( $header ); + + $this->assertEquals( $expected, $actual ); // shouldn't be sensitive to order + } + +} diff --git a/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php b/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php new file mode 100644 index 000000000000..4509a61eb708 --- /dev/null +++ b/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php @@ -0,0 +1,60 @@ +<?php +/* + * Copyright 2019 Wikimedia Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed + * under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS + * OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +/** + * @group Media + * @covers MSCompoundFileReader + */ +class MSCompoundFileReaderTest extends PHPUnit\Framework\TestCase { + public static function provideValid() { + return [ + [ 'calc.xls', 'application/vnd.ms-excel' ], + [ 'excel2016-compat97.xls', 'application/vnd.ms-excel' ], + [ 'gnumeric.xls', 'application/vnd.ms-excel' ], + [ 'impress.ppt', 'application/vnd.ms-powerpoint' ], + [ 'powerpoint2016-compat97.ppt', 'application/vnd.ms-powerpoint' ], + [ 'word2016-compat97.doc', 'application/msword' ], + [ 'writer.doc', 'application/msword' ], + ]; + } + + /** @dataProvider provideValid */ + public function testReadFile( $fileName, $expectedMime ) { + global $IP; + + $info = MSCompoundFileReader::readFile( "$IP/tests/phpunit/data/MSCompoundFileReader/$fileName" ); + $this->assertTrue( $info['valid'] ); + $this->assertSame( $expectedMime, $info['mime'] ); + } + + public static function provideInvalid() { + return [ + [ 'dir-beyond-end.xls', 'ERROR_READ_PAST_END' ], + [ 'fat-loop.xls', 'ERROR_INVALID_FORMAT' ], + [ 'invalid-signature.xls', 'ERROR_INVALID_SIGNATURE' ], + ]; + } + + /** @dataProvider provideInvalid */ + public function testReadFileInvalid( $fileName, $expectedError ) { + global $IP; + + $info = MSCompoundFileReader::readFile( "$IP/tests/phpunit/data/MSCompoundFileReader/$fileName" ); + $this->assertFalse( $info['valid'] ); + $this->assertSame( constant( MSCompoundFileReader::class . '::' . $expectedError ), + $info['errorCode'] ); + } +} diff --git a/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php new file mode 100644 index 000000000000..194781207e9f --- /dev/null +++ b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php @@ -0,0 +1,140 @@ +<?php +/** + * @group Media + * @covers MimeAnalyzer + */ +class MimeAnalyzerTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** @var MimeAnalyzer */ + private $mimeAnalyzer; + + function setUp() { + global $IP; + + $this->mimeAnalyzer = new MimeAnalyzer( [ + 'infoFile' => $IP . "/includes/libs/mime/mime.info", + 'typeFile' => $IP . "/includes/libs/mime/mime.types", + 'xmlTypes' => [ + 'http://www.w3.org/2000/svg:svg' => 'image/svg+xml', + 'svg' => 'image/svg+xml', + 'http://www.lysator.liu.se/~alla/dia/:diagram' => 'application/x-dia-diagram', + 'http://www.w3.org/1999/xhtml:html' => 'text/html', // application/xhtml+xml? + 'html' => 'text/html', // application/xhtml+xml? + ] + ] ); + parent::setUp(); + } + + function doGuessMimeType( array $parameters = [] ) { + $class = new ReflectionClass( get_class( $this->mimeAnalyzer ) ); + $method = $class->getMethod( 'doGuessMimeType' ); + $method->setAccessible( true ); + return $method->invokeArgs( $this->mimeAnalyzer, $parameters ); + } + + /** + * @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->mimeAnalyzer->improveTypeFromExtension( $oldMime, $ext ); + $this->assertEquals( $expectedMime, $actualMime ); + } + + function providerImproveTypeFromExtension() { + return [ + [ 'gif', 'image/gif', 'image/gif' ], + [ 'gif', 'unknown/unknown', 'unknown/unknown' ], + [ 'wrl', 'unknown/unknown', 'model/vrml' ], + [ 'txt', 'text/plain', 'text/plain' ], + [ 'csv', 'text/plain', 'text/csv' ], + [ 'tsv', 'text/plain', 'text/tab-separated-values' ], + [ 'js', 'text/javascript', 'application/javascript' ], + [ 'js', 'application/x-javascript', 'application/javascript' ], + [ 'json', 'text/plain', 'application/json' ], + [ 'foo', 'application/x-opc+zip', 'application/zip' ], + [ 'docx', 'application/x-opc+zip', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ], + [ 'djvu', 'image/x-djvu', 'image/vnd.djvu' ], + [ 'wav', 'audio/wav', 'audio/wav' ], + ]; + } + + /** + * Test to make sure that encoder=ffmpeg2theora doesn't trigger + * MEDIATYPE_VIDEO (T65584) + */ + function testOggRecognize() { + $oggFile = __DIR__ . '/../../../data/media/say-test.ogg'; + $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that Opus audio files don't trigger + * MEDIATYPE_MULTIMEDIA (bug T151352) + */ + function testOpusRecognize() { + $oggFile = __DIR__ . '/../../../data/media/say-test.opus'; + $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that mp3 files are detected as audio type + */ + function testMP3AsAudio() { + $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3'; + $actualType = $this->mimeAnalyzer->getMediaType( $file ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that MP3 with id3 tag is recognized + */ + function testMP3WithID3Recognize() { + $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-1 sample rates) + */ + function testMP3NoID3RecognizeMPEG1() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg1.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-2 sample rates) + */ + function testMP3NoID3RecognizeMPEG2() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg2.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-2.5 sample rates) + */ + function testMP3NoID3RecognizeMPEG2_5() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg2.5.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * A ZIP file embedded in the middle of a .doc file is still a Word Document. + */ + function testZipInDoc() { + $file = __DIR__ . '/../../../data/media/zip-in-doc.doc'; + $actualType = $this->doGuessMimeType( [ $file, 'doc' ] ); + $this->assertEquals( 'application/msword', $actualType ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php new file mode 100644 index 000000000000..f953319e6770 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php @@ -0,0 +1,159 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group BagOStuff + */ +class CachedBagOStuffTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers CachedBagOStuff::__construct + * @covers CachedBagOStuff::get + */ + public function testGetFromBackend() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + $backend->set( 'foo', 'bar' ); + $this->assertEquals( 'bar', $cache->get( 'foo' ) ); + + $backend->set( 'foo', 'baz' ); + $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' ); + } + + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ + public function testSetAndDelete() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $this->assertEquals( 1, $backend->get( "key$i" ) ); + + $cache->delete( "key$i" ); + $this->assertEquals( false, $cache->get( "key$i" ) ); + $this->assertEquals( false, $backend->get( "key$i" ) ); + } + } + + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ + public function testWriteCacheOnly() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + $cache->set( 'foo', 'bar', 0, CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'bar', $cache->get( 'foo' ) ); + $this->assertFalse( $backend->get( 'foo' ) ); + + $cache->set( 'foo', 'old' ); + $this->assertEquals( 'old', $cache->get( 'foo' ) ); + $this->assertEquals( 'old', $backend->get( 'foo' ) ); + + $cache->set( 'foo', 'new', 0, CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'new', $cache->get( 'foo' ) ); + $this->assertEquals( 'old', $backend->get( 'foo' ) ); + + $cache->delete( 'foo', CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend + } + + /** + * @covers CachedBagOStuff::get + */ + public function testCacheBackendMisses() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + // First hit primes the cache with miss from the backend + $this->assertEquals( false, $cache->get( 'foo' ) ); + + // Change the value in the backend + $backend->set( 'foo', true ); + + // Second hit returns the cached miss + $this->assertEquals( false, $cache->get( 'foo' ) ); + + // But a fresh value is read from the backend + $backend->set( 'bar', true ); + $this->assertEquals( true, $cache->get( 'bar' ) ); + } + + /** + * @covers CachedBagOStuff::setDebug + */ + public function testSetDebug() { + $backend = new HashBagOStuff(); + $cache = new CachedBagOStuff( $backend ); + // Access private property 'debugMode' + $backend = TestingAccessWrapper::newFromObject( $backend ); + $cache = TestingAccessWrapper::newFromObject( $cache ); + $this->assertFalse( $backend->debugMode ); + $this->assertFalse( $cache->debugMode ); + + $cache->setDebug( true ); + // Should have set both + $this->assertTrue( $backend->debugMode, 'sets backend' ); + $this->assertTrue( $cache->debugMode, 'sets self' ); + } + + /** + * @covers CachedBagOStuff::deleteObjectsExpiringBefore + */ + public function testExpire() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'deleteObjectsExpiringBefore' ] ) + ->getMock(); + $backend->expects( $this->once() ) + ->method( 'deleteObjectsExpiringBefore' ) + ->willReturn( false ); + + $cache = new CachedBagOStuff( $backend ); + $cache->deleteObjectsExpiringBefore( '20110401000000' ); + } + + /** + * @covers CachedBagOStuff::makeKey + */ + public function testMakeKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeKey' ] ) + ->getMock(); + $backend->method( 'makeKey' ) + ->willReturn( 'special/logic' ); + + // CachedBagOStuff wraps any backend with a process cache + // using HashBagOStuff. Hash has no special key limitations, + // but backends often do. Make sure it uses the backend's + // makeKey() logic, not the one inherited from HashBagOStuff + $cache = new CachedBagOStuff( $backend ); + + $this->assertEquals( 'special/logic', $backend->makeKey( 'special', 'logic' ) ); + $this->assertEquals( 'special/logic', $cache->makeKey( 'special', 'logic' ) ); + } + + /** + * @covers CachedBagOStuff::makeGlobalKey + */ + public function testMakeGlobalKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeGlobalKey' ] ) + ->getMock(); + $backend->method( 'makeGlobalKey' ) + ->willReturn( 'special/logic' ); + + $cache = new CachedBagOStuff( $backend ); + + $this->assertEquals( 'special/logic', $backend->makeGlobalKey( 'special', 'logic' ) ); + $this->assertEquals( 'special/logic', $cache->makeGlobalKey( 'special', 'logic' ) ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php new file mode 100644 index 000000000000..332e23b25be9 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php @@ -0,0 +1,163 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group BagOStuff + */ +class HashBagOStuffTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers HashBagOStuff::__construct + */ + public function testConstruct() { + $this->assertInstanceOf( + HashBagOStuff::class, + new HashBagOStuff() + ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadZero() { + $cache = new HashBagOStuff( [ 'maxKeys' => 0 ] ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadNeg() { + $cache = new HashBagOStuff( [ 'maxKeys' => -1 ] ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadType() { + $cache = new HashBagOStuff( [ 'maxKeys' => 'x' ] ); + } + + /** + * @covers HashBagOStuff::delete + */ + public function testDelete() { + $cache = new HashBagOStuff(); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $cache->delete( "key$i" ); + $this->assertEquals( false, $cache->get( "key$i" ) ); + } + } + + /** + * @covers HashBagOStuff::clear + */ + public function testClear() { + $cache = new HashBagOStuff(); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + } + $cache->clear(); + for ( $i = 0; $i < 10; $i++ ) { + $this->assertEquals( false, $cache->get( "key$i" ) ); + } + } + + /** + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::expire + */ + public function testExpire() { + $cache = new HashBagOStuff(); + $cacheInternal = TestingAccessWrapper::newFromObject( $cache ); + $cache->set( 'foo', 1 ); + $cache->set( 'bar', 1, 10 ); + $cache->set( 'baz', 1, -10 ); + + $this->assertEquals( 0, $cacheInternal->bag['foo'][$cache::KEY_EXP], 'Indefinite' ); + // 2 seconds tolerance + $this->assertEquals( time() + 10, $cacheInternal->bag['bar'][$cache::KEY_EXP], 'Future', 2 ); + $this->assertEquals( time() - 10, $cacheInternal->bag['baz'][$cache::KEY_EXP], 'Past', 2 ); + + $this->assertEquals( 1, $cache->get( 'bar' ), 'Key not expired' ); + $this->assertEquals( false, $cache->get( 'baz' ), 'Key expired' ); + } + + /** + * Ensure maxKeys eviction prefers keeping new keys. + * + * @covers HashBagOStuff::set + */ + public function testEvictionAdd() { + $cache = new HashBagOStuff( [ 'maxKeys' => 10 ] ); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + } + for ( $i = 10; $i < 20; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $this->assertEquals( false, $cache->get( "key" . ( $i - 10 ) ) ); + } + } + + /** + * Ensure maxKeys eviction prefers recently set keys + * even if the keys pre-exist. + * + * @covers HashBagOStuff::set + */ + public function testEvictionSet() { + $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); + + foreach ( [ 'foo', 'bar', 'baz' ] as $key ) { + $cache->set( $key, 1 ); + } + + // Set existing key + $cache->set( 'foo', 1 ); + + // Add a 4th key (beyond the allowed maximum) + $cache->set( 'quux', 1 ); + + // Foo's life should have been extended over Bar + foreach ( [ 'foo', 'baz', 'quux' ] as $key ) { + $this->assertEquals( 1, $cache->get( $key ), "Kept $key" ); + } + $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' ); + } + + /** + * Ensure maxKeys eviction prefers recently retrieved keys (LRU). + * + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::hasKey + */ + public function testEvictionGet() { + $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); + + foreach ( [ 'foo', 'bar', 'baz' ] as $key ) { + $cache->set( $key, 1 ); + } + + // Get existing key + $cache->get( 'foo', 1 ); + + // Add a 4th key (beyond the allowed maximum) + $cache->set( 'quux', 1 ); + + // Foo's life should have been extended over Bar + foreach ( [ 'foo', 'baz', 'quux' ] as $key ) { + $this->assertEquals( 1, $cache->get( $key ), "Kept $key" ); + } + $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php new file mode 100644 index 000000000000..550ec0bd0919 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php @@ -0,0 +1,62 @@ +<?php + +class ReplicatedBagOStuffTest extends MediaWikiTestCase { + /** @var HashBagOStuff */ + private $writeCache; + /** @var HashBagOStuff */ + private $readCache; + /** @var ReplicatedBagOStuff */ + private $cache; + + protected function setUp() { + parent::setUp(); + + $this->writeCache = new HashBagOStuff(); + $this->readCache = new HashBagOStuff(); + $this->cache = new ReplicatedBagOStuff( [ + 'writeFactory' => $this->writeCache, + 'readFactory' => $this->readCache, + ] ); + } + + /** + * @covers ReplicatedBagOStuff::set + */ + public function testSet() { + $key = 'a key'; + $value = 'a value'; + $this->cache->set( $key, $value ); + + // Write to master. + $this->assertEquals( $value, $this->writeCache->get( $key ) ); + // Don't write to replica. Replication is deferred to backend. + $this->assertFalse( $this->readCache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGet() { + $key = 'a key'; + + $write = 'one value'; + $this->writeCache->set( $key, $write ); + $read = 'another value'; + $this->readCache->set( $key, $read ); + + // Read from replica. + $this->assertEquals( $read, $this->cache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGetAbsent() { + $key = 'a key'; + $value = 'a value'; + $this->writeCache->set( $key, $value ); + + // Don't read from master. No failover if value is absent. + $this->assertFalse( $this->cache->get( $key ) ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php new file mode 100644 index 000000000000..017d745e49ed --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php @@ -0,0 +1,1867 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @covers WANObjectCache::wrap + * @covers WANObjectCache::unwrap + * @covers WANObjectCache::worthRefreshExpiring + * @covers WANObjectCache::worthRefreshPopular + * @covers WANObjectCache::isValid + * @covers WANObjectCache::getWarmupKeyMisses + * @covers WANObjectCache::prefixCacheKeys + * @covers WANObjectCache::getProcessCache + * @covers WANObjectCache::getNonProcessCachedKeys + * @covers WANObjectCache::getRawKeysForWarmup + * @covers WANObjectCache::getInterimValue + * @covers WANObjectCache::setInterimValue + */ +class WANObjectCacheTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** @var WANObjectCache */ + private $cache; + /** @var BagOStuff */ + private $internalCache; + + protected function setUp() { + parent::setUp(); + + $this->cache = new WANObjectCache( [ + 'cache' => new HashBagOStuff() + ] ); + + $wanCache = TestingAccessWrapper::newFromObject( $this->cache ); + /** @noinspection PhpUndefinedFieldInspection */ + $this->internalCache = $wanCache->cache; + } + + /** + * @dataProvider provideSetAndGet + * @covers WANObjectCache::set() + * @covers WANObjectCache::get() + * @covers WANObjectCache::makeKey() + * @param mixed $value + * @param int $ttl + */ + public function testSetAndGet( $value, $ttl ) { + $curTTL = null; + $asOf = null; + $key = $this->cache->makeKey( 'x', wfRandomString() ); + + $this->cache->get( $key, $curTTL, [], $asOf ); + $this->assertNull( $curTTL, "Current TTL is null" ); + $this->assertNull( $asOf, "Current as-of-time is infinite" ); + + $t = microtime( true ); + $this->cache->set( $key, $value, $ttl ); + + $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) ); + if ( is_infinite( $ttl ) || $ttl == 0 ) { + $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" ); + } else { + $this->assertGreaterThan( 0, $curTTL, "Current TTL > 0" ); + $this->assertLessThanOrEqual( $ttl, $curTTL, "Current TTL < nominal TTL" ); + } + $this->assertGreaterThanOrEqual( $t - 1, $asOf, "As-of-time in range of set() time" ); + $this->assertLessThanOrEqual( $t + 1, $asOf, "As-of-time in range of set() time" ); + } + + public static function provideSetAndGet() { + return [ + [ 14141, 3 ], + [ 3535.666, 3 ], + [ [], 3 ], + [ null, 3 ], + [ '0', 3 ], + [ (object)[ 'meow' ], 3 ], + [ INF, 3 ], + [ '', 3 ], + [ 'pizzacat', INF ], + ]; + } + + /** + * @covers WANObjectCache::get() + * @covers WANObjectCache::makeGlobalKey() + */ + public function testGetNotExists() { + $key = $this->cache->makeGlobalKey( 'y', wfRandomString(), 'p' ); + $curTTL = null; + $value = $this->cache->get( $key, $curTTL ); + + $this->assertFalse( $value, "Non-existing key has false value" ); + $this->assertNull( $curTTL, "Non-existing key has null current TTL" ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testSetOver() { + $key = wfRandomString(); + for ( $i = 0; $i < 3; ++$i ) { + $value = wfRandomString(); + $this->cache->set( $key, $value, 3 ); + + $this->assertEquals( $this->cache->get( $key ), $value ); + } + } + + /** + * @covers WANObjectCache::set() + */ + public function testStaleSet() { + $key = wfRandomString(); + $value = wfRandomString(); + $this->cache->set( $key, $value, 3, [ 'since' => microtime( true ) - 30 ] ); + + $this->assertFalse( $this->cache->get( $key ), "Stale set() value ignored" ); + } + + public function testProcessCache() { + $mockWallClock = 1549343530.2053; + $this->cache->setMockTime( $mockWallClock ); + + $hit = 0; + $callback = function () use ( &$hit ) { + ++$hit; + return 42; + }; + $keys = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $groups = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit, "Values cached" ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit, "New values cached" ); + + foreach ( $keys as $i => $key ) { + // Should evict from process cache + $this->cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + // Get into cache (specific process cache group) + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 9, $hit, "Values evicted by delete()" ); + + // Get into cache (default process cache group) + $key = reset( $keys ); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 9, $hit, "Value recently interim-cached" ); + + $mockWallClock += 0.2; // interim key not brand new + $this->cache->clearProcessCache(); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 10, $hit, "Value calculated (interim key not recent and reset)" ); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 10, $hit, "Value process cached" ); + + $mockWallClock += 0.2; // interim key not brand new + $outerCallback = function () use ( &$callback, $key ) { + $v = $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + + return 43 + $v; + }; + // Outer key misses and refuses inner key process cache value + $this->cache->getWithSetCallback( "$key-miss-outer", 100, $outerCallback ); + $this->assertEquals( 11, $hit, "Nested callback value process cache skipped" ); + } + + /** + * @dataProvider getWithSetCallback_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetWithSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $key = wfRandomString(); + $value = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $priorValue = null; + $priorAsOf = null; + $wasSet = 0; + $func = function ( $old, &$ttl, &$opts, $asOf ) + use ( &$wasSet, &$priorValue, &$priorAsOf, $value ) { + ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; + $ttl = 20; // override with another value + return $value; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); + + $curTTL = null; + $cache->get( $key, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'lowTTL' => 0, 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 0, $wasSet, "Value not regenerated" ); + + $mockWallClock += 1; + + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.2; // interim key is not brand new and check keys have past values + $priorTime = $mockWallClock; // reference time + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $cache->delete( $key ); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $oldValReceived = -1; + $oldAsOfReceived = -1; + $checkFunc = function ( $oldVal, &$ttl, array $setOpts, $oldAsOf ) + use ( &$oldValReceived, &$oldAsOfReceived, &$wasSet ) { + ++$wasSet; + $oldValReceived = $oldVal; + $oldAsOfReceived = $oldAsOf; + + return 'xxx' . $wasSet; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + + $wasSet = 0; + $key = wfRandomString(); + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx1', $v, "Value returned" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock += 40; + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx2', $v, "Value still returned after expired" ); + $this->assertEquals( 2, $wasSet, "Value recalculated while expired" ); + $this->assertEquals( 'xxx1', $oldValReceived, "Callback got stale value" ); + $this->assertNotEquals( null, $oldAsOfReceived, "Callback got stale value" ); + + $mockWallClock += 260; + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx3', $v, "Value still returned after expired" ); + $this->assertEquals( 3, $wasSet, "Value recalculated while expired" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock = ( $priorTime - $cache::HOLDOFF_TTL - 1 ); + $wasSet = 0; + $key = wfRandomString(); + $checkKey = $cache->makeKey( 'template', 'X' ); + $cache->touchCheckKey( $checkKey ); // init check key + $mockWallClock = $priorTime; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value computed" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock += $cache::TTL_HOUR; // some time passes + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Cached value returned" ); + $this->assertEquals( 1, $wasSet, "Cached value returned" ); + + $cache->touchCheckKey( $checkKey ); // make key stale + $mockWallClock += 0.01; // ~1 week left of grace (barely stale to avoid refreshes) + + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value still returned after expired (in grace)" ); + $this->assertEquals( 1, $wasSet, "Value still returned after expired (in grace)" ); + + // Chance of refresh increase to unity as staleness approaches graceTTL + $mockWallClock += $cache::TTL_WEEK; // 8 days of being stale + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx2', $v, "Value was recomputed (past grace)" ); + $this->assertEquals( 2, $wasSet, "Value was recomputed (past grace)" ); + $this->assertEquals( 'xxx1', $oldValReceived, "Callback got post-grace stale value" ); + $this->assertNotEquals( null, $oldAsOfReceived, "Callback got post-grace stale value" ); + } + + /** + * @dataProvider getWithSetCallback_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + function testGetWithSetcallback_touched( array $extOpts, $versioned ) { + $cache = $this->cache; + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $checkFunc = function ( $oldVal, &$ttl, array $setOpts, $oldAsOf ) + use ( &$wasSet ) { + ++$wasSet; + + return 'xxx' . $wasSet; + }; + + $key = wfRandomString(); + $wasSet = 0; + $touched = null; + $touchedCallback = function () use ( &$touched ) { + return $touched; + }; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $mockWallClock += 60; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value was computed once" ); + $this->assertEquals( 1, $wasSet, "Value was computed once" ); + + $touched = $mockWallClock - 10; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $this->assertEquals( 'xxx2', $v, "Value was recomputed once" ); + $this->assertEquals( 2, $wasSet, "Value was recomputed once" ); + } + + public static function getWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + public function testPreemtiveRefresh() { + $value = 'KatCafe'; + $wasSet = 0; + $func = function ( $old, &$ttl, &$opts, $asOf ) use ( &$wasSet, &$value ) + { + ++$wasSet; + return $value; + }; + + $cache = new NearExpiringWANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 30 ]; + $v = $cache->getWithSetCallback( $key, 20, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 0.2; // interim key is not brand new + $v = $cache->getWithSetCallback( $key, 20, $func, $opts ); + $this->assertEquals( 2, $wasSet, "Value re-calculated" ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 1 ]; + $v = $cache->getWithSetCallback( $key, 30, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + $v = $cache->getWithSetCallback( $key, 30, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Value cached" ); + + $asycList = []; + $asyncHandler = function ( $callback ) use ( &$asycList ) { + $asycList[] = $callback; + }; + $cache = new NearExpiringWANObjectCache( [ + 'cache' => new HashBagOStuff(), + 'asyncHandler' => $asyncHandler + ] ); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 100 ]; + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Cached value used" ); + $this->assertEquals( $v, $value, "Value cached" ); + + $mockWallClock += 250; + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Stale value used" ); + $this->assertEquals( 1, count( $asycList ), "Refresh deferred." ); + $value = 'NewCatsInTown'; // change callback return value + $asycList[0](); // run the refresh callback + $asycList = []; + $this->assertEquals( 2, $wasSet, "Value calculated at later time" ); + $this->assertEquals( 0, count( $asycList ), "No deferred refreshes added." ); + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "New value stored" ); + + $cache = new PopularityRefreshingWANObjectCache( [ + 'cache' => new HashBagOStuff() + ] ); + + $mockWallClock = $priorTime; + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'hotTTR' => 900 ]; + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 30; + + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Value cached" ); + + $mockWallClock = $priorTime; + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'hotTTR' => 10 ]; + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 30; + + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 2, $wasSet, "Value re-calculated" ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testGetWithSetCallback_invalidCallback() { + $this->setExpectedException( InvalidArgumentException::class ); + $this->cache->getWithSetCallback( 'key', 30, 'invalid callback' ); + } + + /** + * @dataProvider getMultiWithSetCallback_provider + * @covers WANObjectCache::getMultiWithSetCallback + * @covers WANObjectCache::makeMultiKeys + * @covers WANObjectCache::getMulti + * @param array $extOpts + * @param bool $versioned + */ + public function testGetMultiWithSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $keyA = wfRandomString(); + $keyB = wfRandomString(); + $keyC = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $priorValue = null; + $priorAsOf = null; + $wasSet = 0; + $genFunc = function ( $id, $old, &$ttl, &$opts, $asOf ) use ( + &$wasSet, &$priorValue, &$priorAsOf + ) { + ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; + $ttl = 20; // override with another value + return "@$id$"; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyA => 3353 ] ); + $value = "@3353$"; + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyA], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); + + $curTTL = null; + $cache->get( $keyA, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $value = "@efef$"; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + + $mockWallClock += 1; + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.01; + $priorTime = $mockWallClock; + $value = "@43636$"; + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyC => 43636 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyC], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" ); + $cache->delete( $key ); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $calls = 0; + $ids = [ 1, 2, 3, 4, 5, 6 ]; + $keyFunc = function ( $id, WANObjectCache $wanCache ) { + return $wanCache->makeKey( 'test', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc ); + $genFunc = function ( $id, $oldValue, &$ttl, array &$setops ) use ( &$calls ) { + ++$calls; + + return "val-{$id}"; + }; + $values = $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + + $this->assertEquals( + [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ], + array_values( $values ), + "Correct values in correct order" + ); + $this->assertEquals( + array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ), + array_keys( $values ), + "Correct keys in correct order" + ); + $this->assertEquals( count( $ids ), $calls ); + + $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + $this->assertEquals( count( $ids ), $calls, "Values cached" ); + + // Mock the BagOStuff to assure only one getMulti() call given process caching + $localBag = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'getMulti' ] )->getMock(); + $localBag->expects( $this->exactly( 1 ) )->method( 'getMulti' )->willReturn( [ + WANObjectCache::VALUE_KEY_PREFIX . 'k1' => 'val-id1', + WANObjectCache::VALUE_KEY_PREFIX . 'k2' => 'val-id2' + ] ); + $wanCache = new WANObjectCache( [ 'cache' => $localBag ] ); + + // Warm the process cache + $keyedIds = new ArrayIterator( [ 'k1' => 'id1', 'k2' => 'id2' ] ); + $this->assertEquals( + [ 'k1' => 'val-id1', 'k2' => 'val-id2' ], + $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] ) + ); + // Use the process cache + $this->assertEquals( + [ 'k1' => 'val-id1', 'k2' => 'val-id2' ], + $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] ) + ); + } + + public static function getMultiWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @dataProvider getMultiWithUnionSetCallback_provider + * @covers WANObjectCache::getMultiWithUnionSetCallback() + * @covers WANObjectCache::makeMultiKeys() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetMultiWithUnionSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $keyA = wfRandomString(); + $keyB = wfRandomString(); + $keyC = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $wasSet = 0; + $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use ( + &$wasSet, &$priorValue, &$priorAsOf + ) { + $newValues = []; + foreach ( $ids as $id ) { + ++$wasSet; + $newValues[$id] = "@$id$"; + $ttls[$id] = 20; // override with another value + } + + return $newValues; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyA => 3353 ] ); + $value = "@3353$"; + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, $extOpts ); + $this->assertEquals( $value, $v[$keyA], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + $curTTL = null; + $cache->get( $keyA, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $value = "@efef$"; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + + $mockWallClock += 1; + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.01; + $priorTime = $mockWallClock; + $value = "@43636$"; + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyC => 43636 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyC], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" ); + $cache->delete( $key ); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $calls = 0; + $ids = [ 1, 2, 3, 4, 5, 6 ]; + $keyFunc = function ( $id, WANObjectCache $wanCache ) { + return $wanCache->makeKey( 'test', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc ); + $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use ( &$calls ) { + $newValues = []; + foreach ( $ids as $id ) { + ++$calls; + $newValues[$id] = "val-{$id}"; + } + + return $newValues; + }; + $values = $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc ); + + $this->assertEquals( + [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ], + array_values( $values ), + "Correct values in correct order" + ); + $this->assertEquals( + array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ), + array_keys( $values ), + "Correct keys in correct order" + ); + $this->assertEquals( count( $ids ), $calls ); + + $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc ); + $this->assertEquals( count( $ids ), $calls, "Values cached" ); + } + + public static function getMultiWithUnionSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testLockTSE() { + $cache = $this->cache; + $key = wfRandomString(); + $value = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function () use ( &$calls, $value, $cache, $key ) { + ++$calls; + return $value; + }; + + $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Value was populated' ); + + // Acquire the mutex to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); + $this->assertEquals( 1, $calls, 'Callback was not used' ); + + $cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; interim saved' ); + $this->assertEquals( 2, $calls, 'Callback was used; interim saved' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim (mutex failed)' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used interim (mutex failed)' ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::set() + */ + public function testLockTSESlow() { + $cache = $this->cache; + $key = wfRandomString(); + $key2 = wfRandomString(); + $value = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, &$mockWallClock ) { + ++$calls; + $setOpts['since'] = $mockWallClock - 10; + return $value; + }; + + // Value should be given a low logical TTL due to snapshot lag + $curTTL = null; + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( $value, $cache->get( $key, $curTTL ), 'Value was populated' ); + $this->assertEquals( 1, $curTTL, 'Value has reduced logical TTL', 0.01 ); + $this->assertEquals( 1, $calls, 'Value was generated' ); + + $mockWallClock += 2; // low logical TTL expired + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback used (mutex acquired)' ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was not used (interim value used)' ); + + $mockWallClock += 2; // low logical TTL expired + // Acquire a lock to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was not used (mutex not acquired)' ); + + $mockWallClock += 301; // physical TTL expired + // Acquire a lock to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 3, $calls, 'Callback was used (mutex not acquired, not in cache)' ); + + $calls = 0; + $func2 = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value ) { + ++$calls; + $setOpts['lag'] = 15; + return $value; + }; + + // Value should be given a low logical TTL due to replication lag + $curTTL = null; + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( $value, $cache->get( $key2, $curTTL ), 'Value was populated' ); + $this->assertEquals( 30, $curTTL, 'Value has reduced logical TTL', 0.01 ); + $this->assertEquals( 1, $calls, 'Value was generated' ); + + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Callback was used (not expired)' ); + + $mockWallClock += 31; + + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was used (mutex acquired)' ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testBusyValue() { + $cache = $this->cache; + $key = wfRandomString(); + $value = wfRandomString(); + $busyValue = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function () use ( &$calls, $value, $cache, $key ) { + ++$calls; + return $value; + }; + + $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Value was populated' ); + + $mockWallClock += 0.2; // interim keys not brand new + + // Acquire a lock to verify that getWithSetCallback uses busyValue properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback used' ); + $this->assertEquals( 2, $calls, 'Callback used' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); + $this->assertEquals( 2, $calls, 'Callback was not used' ); + + $cache->delete( $key ); // no value at all anymore and still locked + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' ); + + $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; saved interim' ); + $this->assertEquals( 3, $calls, 'Callback was used; saved interim' ); + + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim' ); + $this->assertEquals( 3, $calls, 'Callback was not used; used interim' ); + } + + /** + * @covers WANObjectCache::getMulti() + */ + public function testGetMulti() { + $cache = $this->cache; + + $value1 = [ 'this' => 'is', 'a' => 'test' ]; + $value2 = [ 'this' => 'is', 'another' => 'test' ]; + + $key1 = wfRandomString(); + $key2 = wfRandomString(); + $key3 = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $cache->set( $key1, $value1, 5 ); + $cache->set( $key2, $value2, 10 ); + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs ), + 'Result array populated' + ); + + $this->assertEquals( 2, count( $curTTLs ), "Two current TTLs in array" ); + $this->assertGreaterThan( 0, $curTTLs[$key1], "Key 1 has current TTL > 0" ); + $this->assertGreaterThan( 0, $curTTLs[$key2], "Key 2 has current TTL > 0" ); + + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $mockWallClock += 1; + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ), + "Result array populated even with new check keys" + ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key 1 generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check key 2 generated on miss' ); + $this->assertEquals( 2, count( $curTTLs ), "Current TTLs array set" ); + $this->assertLessThanOrEqual( 0, $curTTLs[$key1], 'Key 1 has current TTL <= 0' ); + $this->assertLessThanOrEqual( 0, $curTTLs[$key2], 'Key 2 has current TTL <= 0' ); + + $mockWallClock += 1; + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ), + "Result array still populated even with new check keys" + ); + $this->assertEquals( 2, count( $curTTLs ), "Current TTLs still array set" ); + $this->assertLessThan( 0, $curTTLs[$key1], 'Key 1 has negative current TTL' ); + $this->assertLessThan( 0, $curTTLs[$key2], 'Key 2 has negative current TTL' ); + } + + /** + * @covers WANObjectCache::getMulti() + * @covers WANObjectCache::processCheckKeys() + */ + public function testGetMultiCheckKeys() { + $cache = $this->cache; + + $checkAll = wfRandomString(); + $check1 = wfRandomString(); + $check2 = wfRandomString(); + $check3 = wfRandomString(); + $value1 = wfRandomString(); + $value2 = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + // Fake initial check key to be set in the past. Otherwise we'd have to sleep for + // several seconds during the test to assert the behaviour. + foreach ( [ $checkAll, $check1, $check2 ] as $checkKey ) { + $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_NONE ); + } + + $mockWallClock += 0.100; + + $cache->set( 'key1', $value1, 10 ); + $cache->set( 'key2', $value2, 10 ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'Initial values' + ); + $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key1'], 'Initial ttls' ); + $this->assertLessThanOrEqual( 10.5, $curTTLs['key1'], 'Initial ttls' ); + $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key2'], 'Initial ttls' ); + $this->assertLessThanOrEqual( 10.5, $curTTLs['key2'], 'Initial ttls' ); + + $mockWallClock += 0.100; + $cache->touchCheckKey( $check1 ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'key1 expired by check1, but value still provided' + ); + $this->assertLessThan( 0, $curTTLs['key1'], 'key1 TTL expired' ); + $this->assertGreaterThan( 0, $curTTLs['key2'], 'key2 still valid' ); + + $cache->touchCheckKey( $checkAll ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'All keys expired by checkAll, but value still provided' + ); + $this->assertLessThan( 0, $curTTLs['key1'], 'key1 expired by checkAll' ); + $this->assertLessThan( 0, $curTTLs['key2'], 'key2 expired by checkAll' ); + } + + /** + * @covers WANObjectCache::get() + * @covers WANObjectCache::processCheckKeys() + */ + public function testCheckKeyInitHoldoff() { + $cache = $this->cache; + + for ( $i = 0; $i < 500; ++$i ) { + $key = wfRandomString(); + $checkKey = wfRandomString(); + // miss, set, hit + $cache->get( $key, $curTTL, [ $checkKey ] ); + $cache->set( $key, 'val', 10 ); + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $checkKey ] ); + + $this->assertEquals( 'val', $v ); + $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (miss/set/hit)" ); + } + + for ( $i = 0; $i < 500; ++$i ) { + $key = wfRandomString(); + $checkKey = wfRandomString(); + // set, hit + $cache->set( $key, 'val', 10 ); + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $checkKey ] ); + + $this->assertEquals( 'val', $v ); + $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (set/hit)" ); + } + } + + /** + * @covers WANObjectCache::delete + * @covers WANObjectCache::relayDelete + * @covers WANObjectCache::relayPurge + */ + public function testDelete() { + $key = wfRandomString(); + $value = wfRandomString(); + $this->cache->set( $key, $value ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertEquals( $value, $v, "Key was created with value" ); + $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" ); + + $this->cache->delete( $key ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key has false value" ); + $this->assertLessThan( 0, $curTTL, "Deleted key has current TTL < 0" ); + + $this->cache->set( $key, $value . 'more' ); + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key is tombstoned and has false value" ); + $this->assertLessThan( 0, $curTTL, "Deleted key is tombstoned and has current TTL < 0" ); + + $this->cache->set( $key, $value ); + $this->cache->delete( $key, WANObjectCache::HOLDOFF_NONE ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key has false value" ); + $this->assertNull( $curTTL, "Deleted key has null current TTL" ); + + $this->cache->set( $key, $value ); + $v = $this->cache->get( $key, $curTTL ); + $this->assertEquals( $value, $v, "Key was created with value" ); + $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" ); + } + + /** + * @dataProvider getWithSetCallback_versions_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetWithSetCallback_versions( array $extOpts, $versioned ) { + $cache = $this->cache; + + $key = wfRandomString(); + $valueV1 = wfRandomString(); + $valueV2 = [ wfRandomString() ]; + + $wasSet = 0; + $funcV1 = function () use ( &$wasSet, $valueV1 ) { + ++$wasSet; + + return $valueV1; + }; + + $priorValue = false; + $priorAsOf = null; + $funcV2 = function ( $oldValue, &$ttl, $setOpts, $oldAsOf ) + use ( &$wasSet, $valueV2, &$priorValue, &$priorAsOf ) { + $priorValue = $oldValue; + $priorAsOf = $oldAsOf; + ++$wasSet; + + return $valueV2; // new array format + }; + + // Set the main key (version N if versioned) + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts ); + $this->assertEquals( $valueV1, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( $valueV1, $v, "Value not regenerated" ); + + if ( $versioned ) { + // Set the key for version N+1 format + $verOpts = [ 'version' => $extOpts['version'] + 1 ]; + } else { + // Start versioning now with the unversioned key still there + $verOpts = [ 'version' => 1 ]; + } + + // Value goes to secondary key since V1 already used $key + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( false, $priorValue, "Old value not given due to old format" ); + $this->assertEquals( null, $priorAsOf, "Old value not given due to old format" ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value not regenerated (secondary key)" ); + $this->assertEquals( 0, $wasSet, "Value not regenerated (secondary key)" ); + + // Clear out the older or unversioned key + $cache->delete( $key, 0 ); + + // Set the key for next/first versioned format + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value not regenerated (main key)" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated (main key)" ); + } + + public static function getWithSetCallback_versions_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @covers WANObjectCache::useInterimHoldOffCaching + * @covers WANObjectCache::getInterimValue + */ + public function testInterimHoldOffCaching() { + $cache = $this->cache; + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $value = 'CRL-40-940'; + $wasCalled = 0; + $func = function () use ( &$wasCalled, $value ) { + $wasCalled++; + + return $value; + }; + + $cache->useInterimHoldOffCaching( true ); + + $key = wfRandomString( 32 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 1, $wasCalled, 'Value cached' ); + + $cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value interim cached' ); // reuses interim + + $mockWallClock += 0.2; // interim key not brand new + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim + // Lock up the mutex so interim cache is used + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value interim cached (failed mutex)' ); + $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + + $cache->useInterimHoldOffCaching( false ); + + $wasCalled = 0; + $key = wfRandomString( 32 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 1, $wasCalled, 'Value cached' ); + $cache->delete( $key ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value still regenerated (got mutex)' ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 4, $wasCalled, 'Value still regenerated (got mutex)' ); + // Lock up the mutex so interim cache is used + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 5, $wasCalled, 'Value still regenerated (failed mutex)' ); + } + + /** + * @covers WANObjectCache::touchCheckKey + * @covers WANObjectCache::resetCheckKey + * @covers WANObjectCache::getCheckKeyTime + * @covers WANObjectCache::getMultiCheckKeyTime + * @covers WANObjectCache::makePurgeValue + * @covers WANObjectCache::parsePurgeValue + */ + public function testTouchKeys() { + $cache = $this->cache; + $key = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $mockWallClock += 0.100; + $t0 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThanOrEqual( $priorTime, $t0, 'Check key auto-created' ); + + $priorTime = $mockWallClock; + $mockWallClock += 0.100; + $cache->touchCheckKey( $key ); + $t1 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key created' ); + + $t2 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t1, $t2, 'Check key time did not change' ); + + $mockWallClock += 0.100; + $cache->touchCheckKey( $key ); + $t3 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThan( $t2, $t3, 'Check key time increased' ); + + $t4 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t3, $t4, 'Check key time did not change' ); + + $mockWallClock += 0.100; + $cache->resetCheckKey( $key ); + $t5 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThan( $t4, $t5, 'Check key time increased' ); + + $t6 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t5, $t6, 'Check key time did not change' ); + } + + /** + * @covers WANObjectCache::getMulti() + */ + public function testGetWithSeveralCheckKeys() { + $key = wfRandomString(); + $tKey1 = wfRandomString(); + $tKey2 = wfRandomString(); + $value = 'meow'; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $this->cache->setMockTime( $mockWallClock ); + + // Two check keys are newer (given hold-off) than $key, another is older + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 3 ) + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 5 ) + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey1, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 30 ) + ); + $this->cache->set( $key, $value, 30 ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL, [ $tKey1, $tKey2 ] ); + $this->assertEquals( $value, $v, "Value matches" ); + $this->assertLessThan( -4.9, $curTTL, "Correct CTL" ); + $this->assertGreaterThan( -5.1, $curTTL, "Correct CTL" ); + } + + /** + * @covers WANObjectCache::reap() + * @covers WANObjectCache::reapCheckKey() + */ + public function testReap() { + $vKey1 = wfRandomString(); + $vKey2 = wfRandomString(); + $tKey1 = wfRandomString(); + $tKey2 = wfRandomString(); + $value = 'moo'; + + $knownPurge = time() - 60; + $goodTime = microtime( true ) - 5; + $badTime = microtime( true ) - 300; + + $this->internalCache->set( + WANObjectCache::VALUE_KEY_PREFIX . $vKey1, + [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => $value, + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => $goodTime + ] + ); + $this->internalCache->set( + WANObjectCache::VALUE_KEY_PREFIX . $vKey2, + [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => $value, + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => $badTime + ] + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey1, + WANObjectCache::PURGE_VAL_PREFIX . $goodTime + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . $badTime + ); + + $this->assertEquals( $value, $this->cache->get( $vKey1 ) ); + $this->assertEquals( $value, $this->cache->get( $vKey2 ) ); + $this->cache->reap( $vKey1, $knownPurge, $bad1 ); + $this->cache->reap( $vKey2, $knownPurge, $bad2 ); + + $this->assertFalse( $bad1 ); + $this->assertTrue( $bad2 ); + + $this->cache->reapCheckKey( $tKey1, $knownPurge, $tBad1 ); + $this->cache->reapCheckKey( $tKey2, $knownPurge, $tBad2 ); + $this->assertFalse( $tBad1 ); + $this->assertTrue( $tBad2 ); + } + + /** + * @covers WANObjectCache::reap() + */ + public function testReap_fail() { + $backend = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'get', 'changeTTL' ] )->getMock(); + $backend->expects( $this->once() )->method( 'get' ) + ->willReturn( [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => 'value', + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => 300, + ] ); + $backend->expects( $this->once() )->method( 'changeTTL' ) + ->willReturn( false ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $isStale = null; + $ret = $wanCache->reap( 'key', 360, $isStale ); + $this->assertTrue( $isStale, 'value was stale' ); + $this->assertFalse( $ret, 'changeTTL failed' ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testSetWithLag() { + $value = 1; + + $key = wfRandomString(); + $opts = [ 'lag' => 300, 'since' => microtime( true ) ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( $value, $this->cache->get( $key ), "Rep-lagged value written." ); + + $key = wfRandomString(); + $opts = [ 'lag' => 0, 'since' => microtime( true ) - 300 ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Trx-lagged value not written." ); + + $key = wfRandomString(); + $opts = [ 'lag' => 5, 'since' => microtime( true ) - 5 ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Lagged value not written." ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testWritePending() { + $value = 1; + + $key = wfRandomString(); + $opts = [ 'pending' => true ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Pending value not written." ); + } + + public function testMcRouterSupport() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set', 'delete' ] )->getMock(); + $localBag->expects( $this->never() )->method( 'set' ); + $localBag->expects( $this->never() )->method( 'delete' ); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + $valFunc = function () { + return 1; + }; + + // None of these should use broadcasting commands (e.g. SET, DELETE) + $wanCache->get( 'x' ); + $wanCache->get( 'x', $ctl, [ 'check1' ] ); + $wanCache->getMulti( [ 'x', 'y' ] ); + $wanCache->getMulti( [ 'x', 'y' ], $ctls, [ 'check2' ] ); + $wanCache->getWithSetCallback( 'p', 30, $valFunc ); + $wanCache->getCheckKeyTime( 'zzz' ); + $wanCache->reap( 'x', time() - 300 ); + $wanCache->reap( 'zzz', time() - 300 ); + } + + public function testMcRouterSupportBroadcastDelete() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'set' ) + ->with( "/*/mw-wan/" . $wanCache::VALUE_KEY_PREFIX . "test" ); + + $wanCache->delete( 'test' ); + } + + public function testMcRouterSupportBroadcastTouchCK() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'set' ) + ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + + $wanCache->touchCheckKey( 'test' ); + } + + public function testMcRouterSupportBroadcastResetCK() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'delete' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'delete' ) + ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + + $wanCache->resetCheckKey( 'test' ); + } + + public function testEpoch() { + $bag = new HashBagOStuff(); + $cache = new WANObjectCache( [ 'cache' => $bag ] ); + $key = $cache->makeGlobalKey( 'The whole of the Law' ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->set( $key, 'Do what thou Wilt' ); + $cache->touchCheckKey( $key ); + + $then = $now; + $now += 30; + $this->assertEquals( 'Do what thou Wilt', $cache->get( $key ) ); + $this->assertEquals( $then, $cache->getCheckKeyTime( $key ), 'Check key init', 0.01 ); + + $cache = new WANObjectCache( [ + 'cache' => $bag, + 'epoch' => $now - 3600 + ] ); + $cache->setMockTime( $now ); + + $this->assertEquals( 'Do what thou Wilt', $cache->get( $key ) ); + $this->assertEquals( $then, $cache->getCheckKeyTime( $key ), 'Check key kept', 0.01 ); + + $now += 30; + $cache = new WANObjectCache( [ + 'cache' => $bag, + 'epoch' => $now + 3600 + ] ); + $cache->setMockTime( $now ); + + $this->assertFalse( $cache->get( $key ), 'Key rejected due to epoch' ); + $this->assertEquals( $now, $cache->getCheckKeyTime( $key ), 'Check key reset', 0.01 ); + } + + /** + * @dataProvider provideAdaptiveTTL + * @covers WANObjectCache::adaptiveTTL() + * @param float|int $ago + * @param int $maxTTL + * @param int $minTTL + * @param float $factor + * @param int $adaptiveTTL + */ + public function testAdaptiveTTL( $ago, $maxTTL, $minTTL, $factor, $adaptiveTTL ) { + $mtime = $ago ? time() - $ago : $ago; + $margin = 5; + $ttl = $this->cache->adaptiveTTL( $mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + + $ttl = $this->cache->adaptiveTTL( (string)$mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + } + + public static function provideAdaptiveTTL() { + return [ + [ 3600, 900, 30, 0.2, 720 ], + [ 3600, 500, 30, 0.2, 500 ], + [ 3600, 86400, 800, 0.2, 800 ], + [ false, 86400, 800, 0.2, 800 ], + [ null, 86400, 800, 0.2, 800 ] + ]; + } + + /** + * @covers WANObjectCache::__construct + * @covers WANObjectCache::newEmpty + */ + public function testNewEmpty() { + $this->assertInstanceOf( + WANObjectCache::class, + WANObjectCache::newEmpty() + ); + } + + /** + * @covers WANObjectCache::setLogger + */ + public function testSetLogger() { + $this->assertSame( null, $this->cache->setLogger( new Psr\Log\NullLogger ) ); + } + + /** + * @covers WANObjectCache::getQoS + */ + public function testGetQoS() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'getQoS' ] )->getMock(); + $backend->expects( $this->once() )->method( 'getQoS' ) + ->willReturn( BagOStuff::QOS_UNKNOWN ); + $wanCache = new WANObjectCache( [ 'cache' => $backend ] ); + + $this->assertSame( + $wanCache::QOS_UNKNOWN, + $wanCache->getQoS( $wanCache::ATTR_EMULATION ) + ); + } + + /** + * @covers WANObjectCache::makeKey + */ + public function testMakeKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeKey' ] )->getMock(); + $backend->expects( $this->once() )->method( 'makeKey' ) + ->willReturn( 'special' ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $this->assertSame( 'special', $wanCache->makeKey( 'a', 'b' ) ); + } + + /** + * @covers WANObjectCache::makeGlobalKey + */ + public function testMakeGlobalKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeGlobalKey' ] )->getMock(); + $backend->expects( $this->once() )->method( 'makeGlobalKey' ) + ->willReturn( 'special' ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $this->assertSame( 'special', $wanCache->makeGlobalKey( 'a', 'b' ) ); + } + + public static function statsKeyProvider() { + return [ + [ 'domain:page:5', 'page' ], + [ 'domain:main-key', 'main-key' ], + [ 'domain:page:history', 'page' ], + [ 'missingdomainkey', 'missingdomainkey' ] + ]; + } + + /** + * @dataProvider statsKeyProvider + * @covers WANObjectCache::determineKeyClassForStats + */ + public function testStatsKeyClass( $key, $class ) { + $wanCache = TestingAccessWrapper::newFromObject( new WANObjectCache( [ + 'cache' => new HashBagOStuff + ] ) ); + + $this->assertEquals( $class, $wanCache->determineKeyClassForStats( $key ) ); + } +} + +class NearExpiringWANObjectCache extends WANObjectCache { + const CLOCK_SKEW = 1; + + protected function worthRefreshExpiring( $curTTL, $lowTTL ) { + return ( $curTTL > 0 && ( $curTTL + self::CLOCK_SKEW ) < $lowTTL ); + } +} + +class PopularityRefreshingWANObjectCache extends WANObjectCache { + protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) { + return ( ( $now - $asOf ) > $timeTillRefresh ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php new file mode 100644 index 000000000000..5901bc108ccd --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php @@ -0,0 +1,81 @@ +<?php + +/** + * Holds tests for ChronologyProtector abstract MediaWiki 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 + */ + +use Wikimedia\Rdbms\ChronologyProtector; + +/** + * @group Database + * @covers \Wikimedia\Rdbms\ChronologyProtector::__construct + * @covers \Wikimedia\Rdbms\ChronologyProtector::getClientId + */ +class ChronologyProtectorTest extends PHPUnit\Framework\TestCase { + /** + * @dataProvider clientIdProvider + * @param array $client + * @param string $secret + * @param string $expectedId + */ + public function testClientId( array $client, $secret, $expectedId ) { + $bag = new HashBagOStuff(); + $cp = new ChronologyProtector( $bag, $client, null, $secret ); + + $this->assertEquals( $expectedId, $cp->getClientId() ); + } + + public function clientIdProvider() { + return [ + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + '', + '45e93a9c215c031d38b7c42d8e4700ca', + ], + [ + [ + 'ip' => '127.0.0.7', + 'agent' => "Totally-Not-FireFox" + ], + '', + 'b1d604117b51746c35c3df9f293c84dc' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-FireFox" + ], + '', + '731b4e06a65e2346b497fc811571c4d7' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + 'secret', + 'defff51ded73cd901253d874c9b2077d' + ] + ]; + } +} diff --git a/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php b/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php new file mode 100644 index 000000000000..538d625cc2a2 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php @@ -0,0 +1,147 @@ +<?php + +use Wikimedia\Rdbms\TransactionProfiler; +use Psr\Log\LoggerInterface; + +/** + * @covers \Wikimedia\Rdbms\TransactionProfiler + */ +class TransactionProfilerTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + public function testAffected() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 3 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'maxAffected', 100, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 3, true, 200 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 3, true, 200 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 400 ); + } + + public function testReadTime() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per query + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'readQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, false, 1 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, false, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 0, 0 ); + } + + public function testWriteTime() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per query, 1 per trx, and one "sub-optimal trx" entry + $logger->expects( $this->exactly( 4 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, true, 1 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, true, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 20, 1 ); + } + + public function testAffectedTrx() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 1 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'maxAffected', 100, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 200 ); + } + + public function testWriteTimeTrx() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per trx, and one "sub-optimal trx" entry + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 10, 1 ); + } + + public function testConns() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'conns', 2, __METHOD__ ); + + $tp->recordConnection( 'srv1', 'db1', false ); + $tp->recordConnection( 'srv1', 'db2', false ); + $tp->recordConnection( 'srv1', 'db3', false ); // warn + $tp->recordConnection( 'srv1', 'db4', false ); // warn + } + + public function testMasterConns() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'masterConns', 2, __METHOD__ ); + + $tp->recordConnection( 'srv1', 'db1', false ); + $tp->recordConnection( 'srv1', 'db2', false ); + + $tp->recordConnection( 'srv1', 'db1', true ); + $tp->recordConnection( 'srv1', 'db2', true ); + $tp->recordConnection( 'srv1', 'db3', true ); // warn + $tp->recordConnection( 'srv1', 'db4', true ); // warn + } + + public function testReadQueryCount() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'queries', 2, __METHOD__ ); + + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 ); // warn + $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 ); // warn + } + + public function testWriteQueryCount() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writes', 2, __METHOD__ ); + + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1w", microtime( true ) - 0.01, true, 2 ); + $tp->recordQueryCompletion( "SQL 2w", microtime( true ) - 0.01, true, 5 ); + $tp->recordQueryCompletion( "SQL 3w", microtime( true ) - 0.01, true, 3 ); + $tp->recordQueryCompletion( "SQL 4w", microtime( true ) - 0.01, true, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 1 ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php new file mode 100644 index 000000000000..dd86a73eca57 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php @@ -0,0 +1,139 @@ +<?php + +namespace Wikimedia\Tests\Rdbms; + +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\LoadBalancer; +use PHPUnit_Framework_MockObject_MockObject; +use Wikimedia\Rdbms\ConnectionManager; + +/** + * @covers Wikimedia\Rdbms\ConnectionManager + * + * @author Daniel Kinzler + */ +class ConnectionManagerTest extends \PHPUnit\Framework\TestCase { + + /** + * @return IDatabase|PHPUnit_Framework_MockObject_MockObject + */ + private function getIDatabaseMock() { + return $this->getMockBuilder( IDatabase::class ) + ->getMock(); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnection_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new ConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } + + public function testGetReadConnectionRef_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionRef_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnectionRef() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnectionRef(); + + $this->assertSame( $database, $actual ); + } + +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php new file mode 100644 index 000000000000..8d7d104c1e1f --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php @@ -0,0 +1,108 @@ +<?php + +namespace Wikimedia\Tests\Rdbms; + +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\LoadBalancer; +use PHPUnit_Framework_MockObject_MockObject; +use Wikimedia\Rdbms\SessionConsistentConnectionManager; + +/** + * @covers Wikimedia\Rdbms\SessionConsistentConnectionManager + * + * @author Daniel Kinzler + */ +class SessionConsistentConnectionManagerTest extends \PHPUnit\Framework\TestCase { + + /** + * @return IDatabase|PHPUnit_Framework_MockObject_MockObject + */ + private function getIDatabaseMock() { + return $this->getMockBuilder( IDatabase::class ) + ->getMock(); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionReturnsWriteDbOnForceMatser() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testForceMaster() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $manager->getReadConnection(); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php new file mode 100644 index 000000000000..33e5c3b3fb20 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php @@ -0,0 +1,223 @@ +<?php + +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\DBConnRef; +use Wikimedia\Rdbms\FakeResultWrapper; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\ILoadBalancer; +use Wikimedia\Rdbms\ResultWrapper; + +/** + * @covers Wikimedia\Rdbms\DBConnRef + */ +class DBConnRefTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @return ILoadBalancer + */ + private function getLoadBalancerMock() { + $lb = $this->getMock( ILoadBalancer::class ); + + $lb->method( 'getConnection' )->willReturnCallback( + function () { + return $this->getDatabaseMock(); + } + ); + + $lb->method( 'getConnectionRef' )->willReturnCallback( + function () use ( $lb ) { + return $this->getDBConnRef( $lb ); + } + ); + + return $lb; + } + + /** + * @return IDatabase + */ + private function getDatabaseMock() { + $db = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + + $open = true; + $db->method( 'select' )->willReturnCallback( function () use ( &$open ) { + if ( !$open ) { + throw new LogicException( "Not open" ); + } + + return new FakeResultWrapper( [] ); + } ); + $db->method( 'close' )->willReturnCallback( function () use ( &$open ) { + $open = false; + + return true; + } ); + $db->method( 'isOpen' )->willReturnCallback( function () use ( &$open ) { + return $open; + } ); + $db->method( 'open' )->willReturnCallback( function () use ( &$open ) { + $open = true; + + return $open; + } ); + $db->method( '__toString' )->willReturn( 'MOCK_DB' ); + + return $db; + } + + /** + * @return IDatabase + */ + private function getDBConnRef( ILoadBalancer $lb = null ) { + $lb = $lb ?: $this->getLoadBalancerMock(); + return new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); + } + + public function testConstruct() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testConstruct_params() { + $lb = $this->getMock( ILoadBalancer::class ); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ) + ->willReturnCallback( + function () { + return $this->getDatabaseMock(); + } + ); + + $ref = new DBConnRef( + $lb, + [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ], + DB_MASTER + ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertEquals( DB_MASTER, $ref->getReferenceRole() ); + + $ref2 = new DBConnRef( + $lb, + [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ], + DB_REPLICA + ); + $this->assertEquals( DB_REPLICA, $ref2->getReferenceRole() ); + } + + public function testDestruct() { + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ); + + $this->innerMethodForTestDestruct( $lb ); + } + + private function innerMethodForTestDestruct( ILoadBalancer $lb ) { + $ref = $lb->getConnectionRef( DB_REPLICA ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testConstruct_failure() { + $this->setExpectedException( InvalidArgumentException::class, '' ); + + $lb = $this->getLoadBalancerMock(); + new DBConnRef( $lb, 17, DB_REPLICA ); // bad constructor argument + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getDomainId + */ + public function testGetDomainID() { + $lb = $this->getMock( ILoadBalancer::class ); + + // getDomainID is optimized to not create a connection + $lb->expects( $this->never() ) + ->method( 'getConnection' ); + + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + + $this->assertSame( 'dummy', $ref->getDomainID() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::select + */ + public function testSelect() { + // select should get passed through normally + $ref = $this->getDBConnRef(); + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testToString() { + $ref = $this->getDBConnRef(); + $this->assertInternalType( 'string', $ref->__toString() ); + + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'test', 0 ], DB_MASTER ); + $this->assertInternalType( 'string', $ref->__toString() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::close + * @expectedException \Wikimedia\Rdbms\DBUnexpectedError + */ + public function testClose() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_MASTER ); + $ref->close(); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole + */ + public function testGetReferenceRole() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + $this->assertSame( DB_REPLICA, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'dummy', 0 ], DB_MASTER ); + $this->assertSame( DB_MASTER, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ 1, [], 'dummy', 0 ], DB_REPLICA ); + $this->assertSame( DB_REPLICA, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ 0, [], 'dummy', 0 ], DB_MASTER ); + $this->assertSame( DB_MASTER, $ref->getReferenceRole() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole + * @expectedException Wikimedia\Rdbms\DBReadOnlyRoleError + * @dataProvider provideRoleExceptions + */ + public function testRoleExceptions( $method, $args ) { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + $ref->$method( ...$args ); + } + + function provideRoleExceptions() { + return [ + [ 'insert', [ 'table', [ 'a' => 1 ] ] ], + [ 'update', [ 'table', [ 'a' => 1 ], [ 'a' => 2 ] ] ], + [ 'delete', [ 'table', [ 'a' => 1 ] ] ], + [ 'replace', [ 'table', [ 'a' ], [ 'a' => 1 ] ] ], + [ 'upsert', [ 'table', [ 'a' => 1 ], [ 'a' ], [ 'a = a + 1' ] ] ], + [ 'lock', [ 'k', 'method' ] ], + [ 'unlock', [ 'k', 'method' ] ], + [ 'getScopedLockAndFlush', [ 'k', 'method', 1 ] ] + ]; + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php new file mode 100644 index 000000000000..b1d4fadb7d84 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php @@ -0,0 +1,226 @@ +<?php + +use Wikimedia\Rdbms\DatabaseDomain; + +/** + * @covers Wikimedia\Rdbms\DatabaseDomain + */ +class DatabaseDomainTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + public static function provideConstruct() { + return [ + 'All strings' => + [ 'foo', 'bar', 'baz_', 'foo-bar-baz_' ], + 'Nothing' => + [ null, null, '', '' ], + 'Invalid $database' => + [ 0, 'bar', '', '', true ], + 'Invalid $schema' => + [ 'foo', 0, '', '', true ], + 'Invalid $prefix' => + [ 'foo', 'bar', 0, '', true ], + 'Dash' => + [ 'foo-bar', 'baz', 'baa_', 'foo?hbar-baz-baa_' ], + 'Question mark' => + [ 'foo?bar', 'baz', 'baa_', 'foo??bar-baz-baa_' ], + ]; + } + + /** + * @dataProvider provideConstruct + */ + public function testConstruct( $db, $schema, $prefix, $id, $exception = false ) { + if ( $exception ) { + $this->setExpectedException( InvalidArgumentException::class ); + new DatabaseDomain( $db, $schema, $prefix ); + return; + } + + $domain = new DatabaseDomain( $db, $schema, $prefix ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + $this->assertEquals( $id, $domain->getId() ); + $this->assertEquals( $id, strval( $domain ), 'toString' ); + } + + public static function provideNewFromId() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '' ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_' ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + '? is left alone' => + [ 'foo?bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + 'too many parts' => + [ 'foo-bar-baz-baa_', '', '', '', true ], + 'from instance' => + [ DatabaseDomain::newUnspecified(), null, null, '' ], + ]; + } + + /** + * @dataProvider provideNewFromId + */ + public function testNewFromId( $id, $db, $schema, $prefix, $exception = false ) { + if ( $exception ) { + $this->setExpectedException( InvalidArgumentException::class ); + DatabaseDomain::newFromId( $id ); + return; + } + $domain = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + } + + public static function provideEquals() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '' ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_' ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + 'Nothing' => + [ '', null, null, '' ], + ]; + } + + /** + * @dataProvider provideEquals + * @covers Wikimedia\Rdbms\DatabaseDomain::equals + */ + public function testEquals( $id, $db, $schema, $prefix ) { + $fromId = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $fromId ); + + $constructed = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertTrue( $constructed->equals( $id ), 'constructed equals string' ); + $this->assertTrue( $fromId->equals( $id ), 'fromId equals string' ); + + $this->assertTrue( $constructed->equals( $fromId ), 'compare constructed to newId' ); + $this->assertTrue( $fromId->equals( $constructed ), 'compare newId to constructed' ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseDomain::newUnspecified + */ + public function testNewUnspecified() { + $domain = DatabaseDomain::newUnspecified(); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertTrue( $domain->equals( '' ) ); + $this->assertSame( null, $domain->getDatabase() ); + $this->assertSame( null, $domain->getSchema() ); + $this->assertSame( '', $domain->getTablePrefix() ); + } + + public static function provideIsCompatible() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '', true ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_', true ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_', true ], + 'db+dontcare_schema+prefix' => + [ 'foo-bar-baz_', 'foo', null, 'baz_', false ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_', true ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_', true ], + 'Nothing' => + [ '', null, null, '', true ], + 'dontcaredb+dontcaredbschema+prefix' => + [ 'mywiki-mediawiki-prefix_', null, null, 'prefix_', false ], + 'db+dontcareschema+prefix' => + [ 'mywiki-schema-prefix_', 'mywiki', null, 'prefix_', false ], + 'postgres-db-jobqueue' => + [ 'postgres-mediawiki-', 'postgres', null, '', false ] + ]; + } + + /** + * @dataProvider provideIsCompatible + * @covers Wikimedia\Rdbms\DatabaseDomain::isCompatible + */ + public function testIsCompatible( $id, $db, $schema, $prefix, $transitive ) { + $compareIdObj = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $compareIdObj ); + + $fromId = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertTrue( $fromId->isCompatible( $id ), 'constructed equals string' ); + $this->assertTrue( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' ); + + $this->assertEquals( $transitive, $compareIdObj->isCompatible( $fromId ), + 'test transitivity of nulls components' ); + } + + public static function provideIsCompatible2() { + return [ + 'db+schema+prefix' => + [ 'mywiki-schema-prefix_', 'thatwiki', 'schema', 'prefix_' ], + 'dontcaredb+dontcaredbschema+prefix' => + [ 'thatwiki-mediawiki-otherprefix_', null, null, 'prefix_' ], + 'db+dontcareschema+prefix' => + [ 'notmywiki-schema-prefix_', 'mywiki', null, 'prefix_' ], + ]; + } + + /** + * @dataProvider provideIsCompatible2 + * @covers Wikimedia\Rdbms\DatabaseDomain::isCompatible + */ + public function testIsCompatible2( $id, $db, $schema, $prefix ) { + $compareIdObj = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $compareIdObj ); + + $fromId = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertFalse( $fromId->isCompatible( $id ), 'constructed equals string' ); + $this->assertFalse( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSchemaWithNoDB1() { + new DatabaseDomain( null, 'schema', '' ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSchemaWithNoDB2() { + DatabaseDomain::newFromId( '-schema-prefix' ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseDomain::isUnspecified + */ + public function testIsUnspecified() { + $domain = new DatabaseDomain( null, null, '' ); + $this->assertTrue( $domain->isUnspecified() ); + $domain = new DatabaseDomain( 'mywiki', null, '' ); + $this->assertFalse( $domain->isUnspecified() ); + $domain = new DatabaseDomain( 'mywiki', null, '' ); + $this->assertFalse( $domain->isUnspecified() ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php new file mode 100644 index 000000000000..414042ddcf82 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php @@ -0,0 +1,62 @@ +<?php + +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\DatabaseMssql; + +class DatabaseMssqlTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @return PHPUnit_Framework_MockObject_MockObject|DatabaseMssql + */ + private function getMockDb() { + return $this->getMockBuilder( DatabaseMssql::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTRING(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTRING(someField,1,2147483647)' ]; + yield [ 'someField', 1, 3333333333, 'SUBSTRING(someField,1,3333333333)' ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $mockDb = $this->getMockDb(); + $output = $mockDb->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $mockDb = $this->getMockDb(); + $this->setExpectedException( InvalidArgumentException::class ); + $mockDb->buildSubstring( 'foo', $start, $length ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseMssql::getAttributes + */ + public function testAttributes() { + $this->assertTrue( DatabaseMssql::getAttributes()[Database::ATTR_SCHEMAS_AS_TABLE_GROUPS] ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php new file mode 100644 index 000000000000..4c9254512894 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php @@ -0,0 +1,740 @@ +<?php +/** + * Holds tests for DatabaseMysqlBase 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 + * @author Antoine Musso + * @copyright © 2013 Antoine Musso + * @copyright © 2013 Wikimedia Foundation and contributors + */ + +use Wikimedia\Rdbms\MySQLMasterPos; +use Wikimedia\TestingAccessWrapper; + +class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @dataProvider provideDiapers + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::addIdentifierQuotes + */ + public function testAddIdentifierQuotes( $expected, $in ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $quoted = $db->addIdentifierQuotes( $in ); + $this->assertEquals( $expected, $quoted ); + } + + /** + * Feeds testAddIdentifierQuotes + * + * Named per T22281 convention. + */ + public static function provideDiapers() { + return [ + // Format: expected, input + [ '``', '' ], + + // Yeah I really hate loosely typed PHP idiocies nowadays + [ '``', null ], + + // Dear codereviewer, guess what addIdentifierQuotes() + // will return with thoses: + [ '``', false ], + [ '`1`', true ], + + // We never know what could happen + [ '`0`', 0 ], + [ '`1`', 1 ], + + // Whatchout! Should probably use something more meaningful + [ "`'`", "'" ], # single quote + [ '`"`', '"' ], # double quote + [ '````', '`' ], # backtick + [ '`’`', '’' ], # apostrophe (look at your encyclopedia) + + // sneaky NUL bytes are lurking everywhere + [ '``', "\0" ], + [ '`xyzzy`', "\0x\0y\0z\0z\0y\0" ], + + // unicode chars + [ + "`\u{0001}a\u{FFFF}b`", + "\u{0001}a\u{FFFF}b" + ], + [ + "`\u{0001}\u{FFFF}`", + "\u{0001}\u{0000}\u{FFFF}\u{0000}" + ], + [ '`☃`', '☃' ], + [ '`メインページ`', 'メインページ' ], + [ '`Басты_бет`', 'Басты_бет' ], + + // Real world: + [ '`Alix`', 'Alix' ], # while( ! $recovered ) { sleep(); } + [ '`Backtick: ```', 'Backtick: `' ], + [ '`This is a test`', 'This is a test' ], + ]; + } + + private function getMockForViews() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'fetchRow', 'query', 'getDBname' ] ) + ->getMock(); + + $db->method( 'query' ) + ->with( $this->anything() ) + ->willReturn( new FakeResultWrapper( [ + (object)[ 'Tables_in_' => 'view1' ], + (object)[ 'Tables_in_' => 'view2' ], + (object)[ 'Tables_in_' => 'myview' ] + ] ) ); + $db->method( 'getDBname' )->willReturn( '' ); + + return $db; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::listViews + */ + public function testListviews() { + $db = $this->getMockForViews(); + + $this->assertEquals( [ 'view1', 'view2', 'myview' ], + $db->listViews() ); + + // Prefix filtering + $this->assertEquals( [ 'view1', 'view2' ], + $db->listViews( 'view' ) ); + $this->assertEquals( [ 'myview' ], + $db->listViews( 'my' ) ); + $this->assertEquals( [], + $db->listViews( 'UNUSED_PREFIX' ) ); + $this->assertEquals( [ 'view1', 'view2', 'myview' ], + $db->listViews( '' ) ); + } + + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testBinLogName() { + $pos = new MySQLMasterPos( "db1052.2424/4643", 1 ); + + $this->assertEquals( "db1052", $pos->getLogName() ); + $this->assertEquals( "db1052.2424", $pos->getLogFile() ); + $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() ); + } + + /** + * @dataProvider provideComparePositions + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testHasReached( + MySQLMasterPos $lowerPos, MySQLMasterPos $higherPos, $match, $hetero + ) { + if ( $match ) { + $this->assertTrue( $lowerPos->channelsMatch( $higherPos ) ); + + if ( $hetero ) { + // Each position is has one channel higher than the other + $this->assertFalse( $higherPos->hasReached( $lowerPos ) ); + } else { + $this->assertTrue( $higherPos->hasReached( $lowerPos ) ); + } + $this->assertTrue( $lowerPos->hasReached( $lowerPos ) ); + $this->assertTrue( $higherPos->hasReached( $higherPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } else { // channels don't match + $this->assertFalse( $lowerPos->channelsMatch( $higherPos ) ); + + $this->assertFalse( $higherPos->hasReached( $lowerPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } + } + + public static function provideComparePositions() { + $now = microtime( true ); + + return [ + // Binlog style + [ + new MySQLMasterPos( 'db1034-bin.000976/843431247', $now ), + new MySQLMasterPos( 'db1034-bin.000976/843431248', $now ), + true, + false + ], + [ + new MySQLMasterPos( 'db1034-bin.000976/999', $now ), + new MySQLMasterPos( 'db1034-bin.000976/1000', $now ), + true, + false + ], + [ + new MySQLMasterPos( 'db1034-bin.000976/999', $now ), + new MySQLMasterPos( 'db1035-bin.000976/1000', $now ), + false, + false + ], + // MySQL GTID style + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), + true, + false + ], + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ), + new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), + false, + false + ], + // MariaDB GTID style + [ + new MySQLMasterPos( '255-11-23', $now ), + new MySQLMasterPos( '255-11-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99', $now ), + new MySQLMasterPos( '255-11-100', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-999', $now ), + new MySQLMasterPos( '254-11-1000', $now ), + false, + false + ], + [ + new MySQLMasterPos( '255-11-23,256-12-50', $now ), + new MySQLMasterPos( '255-11-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ), + new MySQLMasterPos( '255-11-1000', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-23,256-12-50', $now ), + new MySQLMasterPos( '255-11-24,155-52-63', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ), + new MySQLMasterPos( '255-11-1000,256-12-51', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50', $now ), + new MySQLMasterPos( '255-13-1000,256-14-49', $now ), + true, + true + ], + [ + new MySQLMasterPos( '253-11-999,255-11-999', $now ), + new MySQLMasterPos( '254-11-1000', $now ), + false, + false + ], + ]; + } + + /** + * @dataProvider provideChannelPositions + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testChannelsMatch( MySQLMasterPos $pos1, MySQLMasterPos $pos2, $matches ) { + $this->assertEquals( $matches, $pos1->channelsMatch( $pos2 ) ); + $this->assertEquals( $matches, $pos2->channelsMatch( $pos1 ) ); + + $roundtripPos = new MySQLMasterPos( (string)$pos1, 1 ); + $this->assertEquals( (string)$pos1, (string)$roundtripPos ); + } + + public static function provideChannelPositions() { + $now = microtime( true ); + + return [ + [ + new MySQLMasterPos( 'db1034-bin.000876/44', $now ), + new MySQLMasterPos( 'db1034-bin.000976/74', $now ), + true + ], + [ + new MySQLMasterPos( 'db1052-bin.000976/999', $now ), + new MySQLMasterPos( 'db1052-bin.000976/1000', $now ), + true + ], + [ + new MySQLMasterPos( 'db1066-bin.000976/9999', $now ), + new MySQLMasterPos( 'db1035-bin.000976/10000', $now ), + false + ], + [ + new MySQLMasterPos( 'db1066-bin.000976/9999', $now ), + new MySQLMasterPos( 'trump2016.000976/10000', $now ), + false + ], + ]; + } + + /** + * @dataProvider provideCommonDomainGTIDs + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testCommonGtidDomains( MySQLMasterPos $pos, MySQLMasterPos $ref, $gtids ) { + $this->assertEquals( $gtids, MySQLMasterPos::getCommonDomainGTIDs( $pos, $ref ) ); + } + + public static function provideCommonDomainGTIDs() { + return [ + [ + new MySQLMasterPos( '255-13-99,256-12-50,257-14-50', 1 ), + new MySQLMasterPos( '255-11-1000', 1 ), + [ '255-13-99' ] + ], + [ + new MySQLMasterPos( + '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' . + '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30', + 1 + ), + new MySQLMasterPos( + '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66', + 1 + ), + [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ] + ] + ]; + } + + /** + * @dataProvider provideLagAmounts + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLag + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLagFromPtHeartbeat + */ + public function testPtHeartbeat( $lag ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ + 'getLagDetectionMethod', 'getHeartbeatData', 'getMasterServerInfo' ] ) + ->getMock(); + + $db->method( 'getLagDetectionMethod' ) + ->willReturn( 'pt-heartbeat' ); + + $db->method( 'getMasterServerInfo' ) + ->willReturn( [ 'serverId' => 172, 'asOf' => time() ] ); + + // Fake the current time. + list( $nowSecFrac, $nowSec ) = explode( ' ', microtime() ); + $now = (float)$nowSec + (float)$nowSecFrac; + // Fake the heartbeat time. + // Work arounds for weak DataTime microseconds support. + $ptTime = $now - $lag; + $ptSec = (int)$ptTime; + $ptSecFrac = ( $ptTime - $ptSec ); + $ptDateTime = new DateTime( "@$ptSec" ); + $ptTimeISO = $ptDateTime->format( 'Y-m-d\TH:i:s' ); + $ptTimeISO .= ltrim( number_format( $ptSecFrac, 6 ), '0' ); + + $db->method( 'getHeartbeatData' ) + ->with( [ 'server_id' => 172 ] ) + ->willReturn( [ $ptTimeISO, $now ] ); + + $db->setLBInfo( 'clusterMasterHost', 'db1052' ); + $lagEst = $db->getLag(); + + $this->assertGreaterThan( $lag - 0.010, $lagEst, "Correct heatbeat lag" ); + $this->assertLessThan( $lag + 0.010, $lagEst, "Correct heatbeat lag" ); + } + + public static function provideLagAmounts() { + return [ + [ 0 ], + [ 0.3 ], + [ 6.5 ], + [ 10.1 ], + [ 200.2 ], + [ 400.7 ], + [ 600.22 ], + [ 1000.77 ], + ]; + } + + /** + * @dataProvider provideGtidData + * @covers Wikimedia\Rdbms\MySQLMasterPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos + */ + public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ + 'useGTIDs', + 'getServerGTIDs', + 'getServerRoleStatus', + 'getServerId', + 'getServerUUID' + ] ) + ->getMock(); + + $db->method( 'useGTIDs' )->willReturn( true ); + $db->method( 'getServerGTIDs' )->willReturn( $gtable ); + $db->method( 'getServerRoleStatus' )->willReturnCallback( + function ( $role ) use ( $rBLtable, $mBLtable ) { + if ( $role === 'SLAVE' ) { + return $rBLtable; + } elseif ( $role === 'MASTER' ) { + return $mBLtable; + } + + return null; + } + ); + $db->method( 'getServerId' )->willReturn( 1 ); + $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' ); + + if ( is_array( $rGTIDs ) ) { + $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getReplicaPos() ); + } + if ( is_array( $mGTIDs ) ) { + $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getMasterPos() ); + } + } + + public static function provideGtidData() { + return [ + // MariaDB + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => null // master + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [ + 'File' => 'host.1600', + 'Position' => '77' + ], + [], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + // MySQL + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + ], + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' . + '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [], // binlog fallback + false + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [], // no replication + [], // no replication + false, + false + ] + ]; + } + + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testSerialize() { + $pos = new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + + $pos = new MySQLMasterPos( '255-11-23', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::isInsertSelectSafe + * @dataProvider provideInsertSelectCases + */ + public function testInsertSelectIsSafe( $insertOpts, $selectOpts, $row, $safe ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getReplicationSafetyInfo' ] ) + ->getMock(); + $db->method( 'getReplicationSafetyInfo' )->willReturn( (object)$row ); + $dbw = TestingAccessWrapper::newFromObject( $db ); + + $this->assertEquals( $safe, $dbw->isInsertSelectSafe( $insertOpts, $selectOpts ) ); + } + + public function provideInsertSelectCases() { + return [ + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 2, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + + ]; + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseMysqlBase::buildIntegerCast + */ + public function testBuildIntegerCast() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + $output = $db->buildIntegerCast( 'fieldName' ); + $this->assertSame( 'CAST( fieldName AS SIGNED )', $output ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setIndexAliases + */ + public function testIndexAliases() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] ) + ->getMock(); + $db->method( 'mysqlRealEscapeString' )->willReturnCallback( + function ( $s ) { + return str_replace( "'", "\\'", $s ); + } + ); + + $db->setIndexAliases( [ 'a_b_idx' => 'a_c_idx' ] ); + $sql = $db->selectSQLText( + 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] ); + + $this->assertEquals( + "SELECT field FROM `zend` FORCE INDEX (a_c_idx) WHERE a = 'x' ", + $sql + ); + + $db->setIndexAliases( [] ); + $sql = $db->selectSQLText( + 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] ); + + $this->assertEquals( + "SELECT field FROM `zend` FORCE INDEX (a_b_idx) WHERE a = 'x' ", + $sql + ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setTableAliases + */ + public function testTableAliases() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] ) + ->getMock(); + $db->method( 'mysqlRealEscapeString' )->willReturnCallback( + function ( $s ) { + return str_replace( "'", "\\'", $s ); + } + ); + + $db->setTableAliases( [ + 'meow' => [ 'dbname' => 'feline', 'schema' => null, 'prefix' => 'cat_' ] + ] ); + $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ ); + + $this->assertEquals( + "SELECT field FROM `feline`.`cat_meow` WHERE a = 'x' ", + $sql + ); + + $db->setTableAliases( [] ); + $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ ); + + $this->assertEquals( + "SELECT field FROM `meow` WHERE a = 'x' ", + $sql + ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php new file mode 100644 index 000000000000..0e133d8c2c05 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -0,0 +1,2164 @@ +<?php + +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\LikeMatch; +use Wikimedia\Rdbms\Database; +use Wikimedia\TestingAccessWrapper; +use Wikimedia\Rdbms\DBTransactionStateError; +use Wikimedia\Rdbms\DBUnexpectedError; +use Wikimedia\Rdbms\DBTransactionError; + +/** + * Test the parts of the Database abstract class that deal + * with creating SQL text. + */ +class DatabaseSQLTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** @var DatabaseTestHelper|Database */ + private $database; + + protected function setUp() { + parent::setUp(); + $this->database = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => true ] ); + } + + protected function assertLastSql( $sqlText ) { + $this->assertEquals( + $sqlText, + $this->database->getLastSqls() + ); + } + + protected function assertLastSqlDb( $sqlText, DatabaseTestHelper $db ) { + $this->assertEquals( $sqlText, $db->getLastSqls() ); + } + + /** + * @dataProvider provideSelect + * @covers Wikimedia\Rdbms\Database::select + * @covers Wikimedia\Rdbms\Database::selectSQLText + * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN + * @covers Wikimedia\Rdbms\Database::useIndexClause + * @covers Wikimedia\Rdbms\Database::ignoreIndexClause + * @covers Wikimedia\Rdbms\Database::makeSelectOptions + * @covers Wikimedia\Rdbms\Database::makeOrderBy + * @covers Wikimedia\Rdbms\Database::makeGroupByWithHaving + * @covers Wikimedia\Rdbms\Database::selectFieldsOrOptionsAggregate + * @covers Wikimedia\Rdbms\Database::selectOptionsIncludeLocking + */ + public function testSelect( $sql, $sqlText ) { + $this->database->select( + $sql['tables'], + $sql['fields'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelect() { + return [ + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [ 'alias' => 'text' ], + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => 'alias = \'text\'', + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [], + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '', + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '0', // T188314 + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE 0" + ], + [ + [ + // 'tables' with space prepended indicates pre-escaped table name + 'tables' => ' table LEFT JOIN table2', + 'fields' => [ 'field' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT field FROM table LEFT JOIN table2 WHERE field = 'text'" + ], + [ + [ + // Empty 'tables' is allowed + 'tables' => '', + 'fields' => [ 'SPECIAL_QUERY()' ], + ], + "SELECT SPECIAL_QUERY()" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + 'join_conds' => [ 't2' => [ + '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" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'GROUP BY' => 'field', 'HAVING' => 'COUNT(*) > 1' ], + 'join_conds' => [ 't2' => [ + '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" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ + 'LIMIT' => 1, + 'GROUP BY' => [ 'field', 'field2' ], + 'HAVING' => [ 'COUNT(*) > 1', 'field' => 1 ] + ], + 'join_conds' => [ 't2' => [ + '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" + ], + [ + [ + 'tables' => [ 'table' ], + 'fields' => [ 'alias' => 'field' ], + 'conds' => [ 'alias' => [ 1, 2, 3, 4 ] ], + ], + "SELECT field AS alias " . + "FROM table " . + "WHERE alias IN ('1','2','3','4')" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'USE INDEX' => [ 'table' => 'X' ] ], + ], + // No-op by default + "SELECT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'IGNORE INDEX' => [ 'table' => 'X' ] ], + ], + // No-op by default + "SELECT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'DISTINCT' ], + ], + "SELECT DISTINCT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'LOCK IN SHARE MODE' ], + ], + "SELECT field FROM table LOCK IN SHARE MODE" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'EXPLAIN' => true ], + ], + 'EXPLAIN SELECT field FROM table' + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'FOR UPDATE' ], + ], + "SELECT field FROM table FOR UPDATE" + ], + ]; + } + + /** + * @dataProvider provideLockForUpdate + * @covers Wikimedia\Rdbms\Database::lockForUpdate + */ + public function testLockForUpdate( $sql, $sqlText ) { + $this->database->startAtomic( __METHOD__ ); + $this->database->lockForUpdate( + $sql['tables'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->database->endAtomic( __METHOD__ ); + + $this->assertLastSql( "BEGIN; $sqlText; COMMIT" ); + } + + public static function provideLockForUpdate() { + return [ + [ + [ + 'tables' => [ 'table' ], + 'conds' => [ 'field' => [ 1, 2, 3, 4 ] ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field IN ('1','2','3','4') " . + "FOR UPDATE) tmp_count" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'conds' => [ 'field' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + 'join_conds' => [ 't2' => [ + 'LEFT JOIN', 'tid = t2.id' + ] ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE field = 'text' ORDER BY field LIMIT 1 FOR UPDATE) tmp_count" + ], + [ + [ + 'tables' => 'table', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table FOR UPDATE) tmp_count" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Subquery + * @dataProvider provideSelectRowCount + * @param array $sql + * @param string $sqlText + */ + public function testSelectRowCount( $sql, $sqlText ) { + $this->database->selectRowCount( + $sql['tables'], + $sql['field'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelectRowCount() { + return [ + [ + [ + 'tables' => 'table', + 'field' => [ '*' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => false, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => null, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '1', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (1) AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '0', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (0) AND (column IS NOT NULL) ) tmp_count" + ], + ]; + } + + /** + * @dataProvider provideUpdate + * @covers Wikimedia\Rdbms\Database::update + * @covers Wikimedia\Rdbms\Database::makeUpdateOptions + * @covers Wikimedia\Rdbms\Database::makeUpdateOptionsArray + */ + public function testUpdate( $sql, $sqlText ) { + $this->database->update( + $sql['table'], + $sql['values'], + $sql['conds'], + __METHOD__, + $sql['options'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpdate() { + return [ + [ + [ + 'table' => 'table', + 'values' => [ 'field' => 'text', 'field2' => 'text2' ], + 'conds' => [ 'alias' => 'text' ], + ], + "UPDATE table " . + "SET field = 'text'" . + ",field2 = 'text2' " . + "WHERE alias = 'text'" + ], + [ + [ + 'table' => 'table', + 'values' => [ 'field = other', 'field2' => 'text2' ], + 'conds' => [ 'id' => '1' ], + ], + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2' " . + "WHERE id = '1'" + ], + [ + [ + 'table' => 'table', + 'values' => [ 'field = other', 'field2' => 'text2' ], + 'conds' => '*', + ], + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2'" + ], + ]; + } + + /** + * @dataProvider provideDelete + * @covers Wikimedia\Rdbms\Database::delete + */ + public function testDelete( $sql, $sqlText ) { + $this->database->delete( + $sql['table'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDelete() { + return [ + [ + [ + 'table' => 'table', + 'conds' => [ 'alias' => 'text' ], + ], + "DELETE FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'table' => 'table', + 'conds' => '*', + ], + "DELETE FROM table" + ], + ]; + } + + /** + * @dataProvider provideUpsert + * @covers Wikimedia\Rdbms\Database::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 [ + [ + [ + 'table' => 'upsert_table', + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + 'uniqueIndexes' => [ 'field' ], + 'set' => [ '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 Wikimedia\Rdbms\Database::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 [ + [ + [ + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => [ 'alias' => 'text' ], + ], + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join WHERE alias = 'text'" . + ")" + ], + [ + [ + '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 Wikimedia\Rdbms\Database::insert + * @covers Wikimedia\Rdbms\Database::makeInsertOptions + */ + public function testInsert( $sql, $sqlText ) { + $this->database->insert( + $sql['table'], + $sql['rows'], + __METHOD__, + $sql['options'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsert() { + return [ + [ + [ + 'table' => 'table', + 'rows' => [ 'field' => 'text', 'field2' => 2 ], + ], + "INSERT INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ], + [ + [ + 'table' => 'table', + 'rows' => [ 'field' => 'text', 'field2' => 2 ], + 'options' => 'IGNORE', + ], + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ], + [ + [ + 'table' => 'table', + 'rows' => [ + [ 'field' => 'text', 'field2' => 2 ], + [ 'field' => 'multi', 'field2' => 3 ], + ], + 'options' => 'IGNORE', + ], + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES " . + "('text','2')," . + "('multi','3')" + ], + ]; + } + + /** + * @dataProvider provideInsertSelect + * @covers Wikimedia\Rdbms\Database::insertSelect + * @covers Wikimedia\Rdbms\Database::nativeInsertSelect + */ + public function testInsertSelect( $sql, $sqlTextNative, $sqlSelect, $sqlInsert ) { + $this->database->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + $sql['insertOptions'] ?? [], + $sql['selectOptions'] ?? [], + $sql['selectJoinConds'] ?? [] + ); + $this->assertLastSql( $sqlTextNative ); + + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $dbWeb->forceNextResult( [ + array_flip( array_keys( $sql['varMap'] ) ) + ] ); + $dbWeb->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + $sql['insertOptions'] ?? [], + $sql['selectOptions'] ?? [], + $sql['selectJoinConds'] ?? [] + ); + $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, 'BEGIN', $sqlInsert, 'COMMIT' ] ), $dbWeb ); + } + + public static function provideInsertSelect() { + return [ + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => '*', + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2'", + "SELECT field_select AS field_insert,field2 AS field FROM " . + "select_table WHERE field = '2' FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + 'insertOptions' => 'IGNORE', + 'selectOptions' => [ 'ORDER BY' => 'field' ], + ], + "INSERT IGNORE INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2' " . + "ORDER BY field", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table WHERE field = '2' ORDER BY field FOR UPDATE", + "INSERT IGNORE INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => [ 'select_table1', 'select_table2' ], + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + 'insertOptions' => [ 'NO_AUTO_COLUMNS' ], + 'selectOptions' => [ 'ORDER BY' => 'field', 'FORCE INDEX' => [ 'select_table1' => 'index1' ] ], + 'selectJoinConds' => [ + 'select_table2' => [ 'LEFT JOIN', [ 'select_table1.foo = select_table2.bar' ] ], + ], + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " . + "WHERE field = '2' " . + "ORDER BY field", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " . + "WHERE field = '2' ORDER BY field FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::insertSelect + * @covers Wikimedia\Rdbms\Database::nativeInsertSelect + */ + public function testInsertSelectBatching() { + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $rows = []; + for ( $i = 0; $i <= 25000; $i++ ) { + $rows[] = [ 'field' => $i ]; + } + $dbWeb->forceNextResult( $rows ); + $dbWeb->insertSelect( + 'insert_table', + 'select_table', + [ 'field' => 'field2' ], + '*', + __METHOD__ + ); + $this->assertLastSqlDb( implode( '; ', [ + 'SELECT field2 AS field FROM select_table FOR UPDATE', + 'BEGIN', + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 0, 9999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 10000, 19999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 20000, 25000 ) ) . "')", + 'COMMIT' + ] ), $dbWeb ); + } + + /** + * @dataProvider provideReplace + * @covers Wikimedia\Rdbms\Database::replace + */ + public function testReplace( $sql, $sqlText ) { + $this->database->replace( + $sql['table'], + $sql['uniqueIndexes'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideReplace() { + return [ + [ + [ + 'table' => 'replace_table', + 'uniqueIndexes' => [ 'field' ], + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + ], + "BEGIN; DELETE FROM replace_table " . + "WHERE (field = 'text'); " . + "INSERT INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ], + 'rows' => [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], + ], + "BEGIN; 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'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ], + 'rows' => [ + [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], [ + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ], + ], + ], + "BEGIN; 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'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ 'md_module', 'md_skin' ], + 'rows' => [ + [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], [ + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ], + ], + ], + "BEGIN; 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'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [], + 'rows' => [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], + ], + "BEGIN; INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); COMMIT" + ], + ]; + } + + /** + * @dataProvider provideNativeReplace + * @covers Wikimedia\Rdbms\Database::nativeReplace + */ + public function testNativeReplace( $sql, $sqlText ) { + $this->database->nativeReplace( + $sql['table'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideNativeReplace() { + return [ + [ + [ + 'table' => 'replace_table', + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + ], + "REPLACE INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ], + ]; + } + + /** + * @dataProvider provideConditional + * @covers Wikimedia\Rdbms\Database::conditional + */ + public function testConditional( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->conditional( + $sql['conds'], + $sql['true'], + $sql['false'] + ) ), $sqlText ); + } + + public static function provideConditional() { + return [ + [ + [ + 'conds' => [ 'field' => 'text' ], + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field = 'text' THEN 1 ELSE NULL END)" + ], + [ + [ + 'conds' => [ 'field' => 'text', 'field2' => 'anothertext' ], + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field = 'text' AND field2 = 'anothertext' THEN 1 ELSE NULL END)" + ], + [ + [ + 'conds' => 'field=1', + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field=1 THEN 1 ELSE NULL END)" + ], + ]; + } + + /** + * @dataProvider provideBuildConcat + * @covers Wikimedia\Rdbms\Database::buildConcat + */ + public function testBuildConcat( $stringList, $sqlText ) { + $this->assertEquals( trim( $this->database->buildConcat( + $stringList + ) ), $sqlText ); + } + + public static function provideBuildConcat() { + return [ + [ + [ 'field', 'field2' ], + "CONCAT(field,field2)" + ], + [ + [ "'test'", 'field2' ], + "CONCAT('test',field2)" + ], + ]; + } + + /** + * @dataProvider provideBuildLike + * @covers Wikimedia\Rdbms\Database::buildLike + * @covers Wikimedia\Rdbms\Database::escapeLikeInternal + */ + public function testBuildLike( $array, $sqlText ) { + $this->assertEquals( trim( $this->database->buildLike( + $array + ) ), $sqlText ); + } + + public static function provideBuildLike() { + return [ + [ + 'text', + "LIKE 'text' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '%' ) ], + "LIKE 'text%' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '%' ), 'text2' ], + "LIKE 'text%text2' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '_' ) ], + "LIKE 'text_' ESCAPE '`'" + ], + [ + 'more_text', + "LIKE 'more`_text' ESCAPE '`'" + ], + [ + [ 'C:\\Windows\\', new LikeMatch( '%' ) ], + "LIKE 'C:\\Windows\\%' ESCAPE '`'" + ], + [ + [ 'accent`_test`', new LikeMatch( '%' ) ], + "LIKE 'accent```_test``%' ESCAPE '`'" + ], + ]; + } + + /** + * @dataProvider provideUnionQueries + * @covers Wikimedia\Rdbms\Database::unionQueries + */ + public function testUnionQueries( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->unionQueries( + $sql['sqls'], + $sql['all'] + ) ), $sqlText ); + } + + public static function provideUnionQueries() { + return [ + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL' ], + 'all' => true, + ], + "(RAW SQL) UNION ALL (RAW2SQL)" + ], + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL' ], + 'all' => false, + ], + "(RAW SQL) UNION (RAW2SQL)" + ], + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL', 'RAW3SQL' ], + 'all' => false, + ], + "(RAW SQL) UNION (RAW2SQL) UNION (RAW3SQL)" + ], + ]; + } + + /** + * @dataProvider provideUnionConditionPermutations + * @covers Wikimedia\Rdbms\Database::unionConditionPermutations + */ + public function testUnionConditionPermutations( $params, $expect ) { + if ( isset( $params['unionSupportsOrderAndLimit'] ) ) { + $this->database->setUnionSupportsOrderAndLimit( $params['unionSupportsOrderAndLimit'] ); + } + + $sql = trim( $this->database->unionConditionPermutations( + $params['table'], + $params['vars'], + $params['permute_conds'], + $params['extra_conds'] ?? '', + 'FNAME', + $params['options'] ?? [], + $params['join_conds'] ?? [] + ) ); + $this->assertEquals( $expect, $sql ); + } + + public static function provideUnionConditionPermutations() { + // phpcs:disable Generic.Files.LineLength + return [ + [ + [ + 'table' => [ 'table1', 'table2' ], + 'vars' => [ 'field1', 'alias' => 'field2' ], + 'permute_conds' => [ + 'field3' => [ 1, 2, 3 ], + 'duplicates' => [ 4, 5, 4 ], + 'empty' => [], + 'single' => [ 0 ], + ], + 'extra_conds' => 'table2.bar > 23', + 'options' => [ + 'ORDER BY' => [ 'field1', 'alias' ], + 'INNER ORDER BY' => [ 'field1', 'field2' ], + 'LIMIT' => 100, + ], + 'join_conds' => [ + 'table2' => [ 'JOIN', 'table1.foo_id = table2.foo_id' ], + ], + ], + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) " . + "ORDER BY field1,alias LIMIT 100" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1, 2, 3 ], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'NOTALL', + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) " . + "ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1, 2, 3 ], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'NOTALL' => true, + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + 'unionSupportsOrderAndLimit' => false, + ], + "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ) " . + "ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1 ], + ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + ], + ], + "SELECT foo_id FROM foo WHERE bar = '1' ORDER BY foo_id LIMIT 150,25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + 'INNER ORDER BY' => [ 'bar_id' ], + ], + ], + "(SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY bar_id LIMIT 175 ) ORDER BY foo_id LIMIT 150,25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + 'INNER ORDER BY' => [ 'bar_id' ], + ], + 'unionSupportsOrderAndLimit' => false, + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 150,25" + ], + ]; + // phpcs:enable + } + + /** + * @covers Wikimedia\Rdbms\Database::commit + * @covers Wikimedia\Rdbms\Database::doCommit + */ + public function testTransactionCommit() { + $this->database->begin( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::rollback + * @covers Wikimedia\Rdbms\Database::doRollback + */ + public function testTransactionRollback() { + $this->database->begin( __METHOD__ ); + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::dropTable + */ + public function testDropTable() { + $this->database->setExistingTables( [ 'table' ] ); + $this->database->dropTable( 'table', __METHOD__ ); + $this->assertLastSql( 'DROP TABLE table CASCADE' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::dropTable + */ + public function testDropNonExistingTable() { + $this->assertFalse( + $this->database->dropTable( 'non_existing', __METHOD__ ) + ); + } + + /** + * @dataProvider provideMakeList + * @covers Wikimedia\Rdbms\Database::makeList + */ + public function testMakeList( $list, $mode, $sqlText ) { + $this->assertEquals( trim( $this->database->makeList( + $list, $mode + ) ), $sqlText ); + } + + public static function provideMakeList() { + return [ + [ + [ 'value', 'value2' ], + LIST_COMMA, + "'value','value2'" + ], + [ + [ 'field', 'field2' ], + LIST_NAMES, + "field,field2" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_AND, + "field = 'value' AND field2 = 'value2'" + ], + [ + [ 'field' => null, "field2 != 'value2'" ], + LIST_AND, + "field IS NULL AND (field2 != 'value2')" + ], + [ + [ 'field' => [ 'value', null, 'value2' ], 'field2' => 'value2' ], + LIST_AND, + "(field IN ('value','value2') OR field IS NULL) AND field2 = 'value2'" + ], + [ + [ 'field' => [ null ], 'field2' => null ], + LIST_AND, + "field IS NULL AND field2 IS NULL" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_OR, + "field = 'value' OR field2 = 'value2'" + ], + [ + [ 'field' => 'value', 'field2' => null ], + LIST_OR, + "field = 'value' OR field2 IS NULL" + ], + [ + [ 'field' => [ 'value', 'value2' ], 'field2' => [ 'value' ] ], + LIST_OR, + "field IN ('value','value2') OR field2 = 'value'" + ], + [ + [ 'field' => [ null, 'value', null, 'value2' ], "field2 != 'value2'" ], + LIST_OR, + "(field IN ('value','value2') OR field IS NULL) OR (field2 != 'value2')" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_SET, + "field = 'value',field2 = 'value2'" + ], + [ + [ 'field' => 'value', 'field2' => null ], + LIST_SET, + "field = 'value',field2 = NULL" + ], + [ + [ 'field' => 'value', "field2 != 'value2'" ], + LIST_SET, + "field = 'value',field2 != 'value2'" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::registerTempTableWrite + */ + public function testSessionTempTables() { + $temp1 = $this->database->tableName( 'tmp_table_1' ); + $temp2 = $this->database->tableName( 'tmp_table_2' ); + $temp3 = $this->database->tableName( 'tmp_table_3' ); + + $this->database->query( "CREATE TEMPORARY TABLE $temp1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp2 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp3 LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->dropTable( 'tmp_table_1', __METHOD__ ); + $this->database->dropTable( 'tmp_table_2', __METHOD__ ); + $this->database->dropTable( 'tmp_table_3', __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "CREATE TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "DROP TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTRING(someField FROM 1 FOR 2)' ]; + yield [ 'someField', 1, null, 'SUBSTRING(someField FROM 1)' ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $output = $this->database->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::buildSubstring + * @covers Wikimedia\Rdbms\Database::assertBuildSubstringParams + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $this->setExpectedException( InvalidArgumentException::class ); + $this->database->buildSubstring( 'foo', $start, $length ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::buildIntegerCast + */ + public function testBuildIntegerCast() { + $output = $this->database->buildIntegerCast( 'fieldName' ); + $this->assertSame( 'CAST( fieldName AS INTEGER )', $output ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSections() { + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $noOpCallack = function () { + }; + + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->doAtomicSection( __METHOD__, $noOpCallack ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->database->rollback( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' ); + + $fname = __METHOD__; + $triggerMap = [ + '-' => '-', + IDatabase::TRIGGER_COMMIT => 'tCommit', + IDatabase::TRIGGER_ROLLBACK => 'tRollback' + ]; + $pcCallback = function ( IDatabase $db ) use ( $fname ) { + $this->database->query( "SELECT 0", $fname ); + }; + $callback1 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 1, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback2 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 2, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback3 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 3, {$triggerMap[$trigger]} AS t", $fname ); + }; + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 0', + 'SELECT 0', + 'COMMIT' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $makeCallback = function ( $id ) use ( $fname, $triggerMap ) { + return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) { + $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname ); + }; + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tRollback AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_level2' ); + $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 2 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $makeCallback( 3 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_level3' ); + $this->database->endAtomic( __METHOD__ . '_level2' ); + $this->database->onTransactionResolution( $makeCallback( 4 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_level1' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'SAVEPOINT wikimedia_rdbms_atomic2', + 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT; SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tRollback AS t', + 'SELECT 4, tCommit AS t' + ] ) ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsRecovery() { + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + $this->database->startAtomic( 'inner_func1' ); + $this->database->startAtomic( 'inner_func2' ); + + throw new RuntimeException( 'Test exception' ); + }, + IDatabase::ATOMIC_CANCELABLE + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + throw new RuntimeException( 'Test exception' ); + } + ); + $this->fail( 'Test exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + try { + $this->database->commit( __METHOD__ ); + $this->fail( 'Test exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsCallbackCancellation() { + $fname = __METHOD__; + $callback1Called = null; + $callback1 = function ( $trigger = '-' ) use ( $fname, &$callback1Called ) { + $callback1Called = $trigger; + $this->database->query( "SELECT 1", $fname ); + }; + $callback2Called = null; + $callback2 = function ( $trigger = '-' ) use ( $fname, &$callback2Called ) { + $callback2Called = $trigger; + $this->database->query( "SELECT 2", $fname ); + }; + $callback3Called = null; + $callback3 = function ( $trigger = '-' ) use ( $fname, &$callback3Called ) { + $callback3Called = $trigger; + $this->database->query( "SELECT 3", $fname ); + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__, $atomicId ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId ); + } catch ( DBUnexpectedError $e ) { + $m = __METHOD__; + $this->assertSame( + "Invalid atomic section ended (got {$m}_X but expected {$m}).", + $e->getMessage() + ); + } + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsTrxRound() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SELECT 1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + } + + public static function provideAtomicSectionMethodsForErrors() { + return [ + [ 'endAtomic' ], + [ 'cancelAtomic' ], + ]; + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testNoAtomicSection( $method ) { + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'No atomic section is open (got ' . __METHOD__ . ').', + $ex->getMessage() + ); + } + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testInvalidAtomicSectionEnded( $method ) { + $this->database->startAtomic( __METHOD__ . 'X' ); + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' . + __METHOD__ . 'X).', + $ex->getMessage() + ); + } + } + + /** + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testUncancellableAtomicSection() { + $this->database->startAtomic( __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ ); + $this->database->select( 'test', '1', [], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + } + + /** + * @expectedException \Wikimedia\Rdbms\DBTransactionStateError + * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed + */ + public function testTransactionErrorState1() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->begin( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionErrorState2() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->startAtomic( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->rollback( __METHOD__ ); + $this->assertEquals( 0, $this->database->trxLevel() ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + $this->database->update( 'y', [ 'a' => 1 ], [ 'field' => 1 ], __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->startAtomic( __METHOD__ ); + $this->database->delete( 'y', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; UPDATE y SET a = \'1\' WHERE field = \'1\'; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM y WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + // Next transaction + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testImplicitTransactionRollback() { + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness' ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + + $this->database->setFlag( Database::DBO_TRX ); + + // Implicit transaction does not get silently rolled back + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + call_user_func( $doError ); + try { + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $e ) { + $this->assertEquals( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $e->getMessage() + ); + } + try { + $this->database->commit( __METHOD__, Database::FLUSHING_INTERNAL ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $e ) { + $this->assertEquals( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $e->getMessage() + ); + } + $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL ); + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK' ); + + // Likewise if there were prior writes + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + call_user_func( $doError ); + try { + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionStateError $e ) { + } + $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; DELETE FROM error WHERE 1; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionStatementRollbackIgnoring() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $warning = []; + $wrapper->deprecationLogger = function ( $msg ) use ( &$warning ) { + $warning[] = $msg; + }; + + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness', [ + 'wasKnownStatementRollbackError' => true, + ] ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + $expectWarning = 'Caller from ' . __METHOD__ . + ' ignored an error originally raised from ' . __CLASS__ . '::SomeCaller: [666] Evilness'; + + // Rollback doesn't raise a warning + $warning = []; + $this->database->startAtomic( __METHOD__ ); + call_user_func( $doError ); + $this->database->rollback( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; DELETE FROM x WHERE field = \'1\'' ); + + // cancelAtomic() doesn't raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + call_user_func( $doError ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM error WHERE 1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + + // Commit does raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; COMMIT' ); + + // Deprecation only gets raised once + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose1() { + $fname = __METHOD__; + $this->database->begin( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->onTransactionResolution( function () use ( $fname ) { + $this->database->query( 'SELECT 2', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + try { + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + "Wikimedia\Rdbms\Database::close: transaction is still open (from $fname).", + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK; SELECT 2' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose2() { + try { + $fname = __METHOD__; + $this->database->startAtomic( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: atomic sections ' . + 'DatabaseSQLTest::testPrematureClose2 are still open.', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose3() { + try { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: ' . + 'mass commit/rollback of peer transaction required (DBO_TRX set).', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose4() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->database->clearFlag( IDatabase::DBO_TRX ); + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; SELECT 1; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers Wikimedia\Rdbms\Database::selectFieldValues() + */ + public function testSelectFieldValues() { + $this->database->forceNextResult( [ + (object)[ 'value' => 'row1' ], + (object)[ 'value' => 'row2' ], + (object)[ 'value' => 'row3' ], + ] ); + + $this->assertSame( + [ 'row1', 'row2', 'row3' ], + $this->database->selectFieldValues( 'table', 'table.field', 'conds', __METHOD__ ) + ); + $this->assertLastSql( 'SELECT table.field AS value FROM table WHERE conds' ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php new file mode 100644 index 000000000000..a886d6bf7635 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php @@ -0,0 +1,60 @@ +<?php + +use Wikimedia\Rdbms\DatabaseSqlite; + +/** + * DatabaseSqliteTest is already defined in mediawiki core hence the 'Rdbms' included in this + * class name. + * The test in core should have mediawiki specific stuff removed and the tests moved to this + * rdbms libs test. + */ +class DatabaseSqliteRdbmsTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @return PHPUnit_Framework_MockObject_MockObject|DatabaseSqlite + */ + private function getMockDb() { + return $this->getMockBuilder( DatabaseSqlite::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $dbMock = $this->getMockDb(); + $output = $dbMock->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $dbMock = $this->getMockDb(); + $this->setExpectedException( InvalidArgumentException::class ); + $dbMock->buildSubstring( 'foo', $start, $length ); + } + +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php new file mode 100644 index 000000000000..8b24791ca600 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php @@ -0,0 +1,707 @@ +<?php + +use Wikimedia\Rdbms\Database; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\DatabaseDomain; +use Wikimedia\Rdbms\DatabaseMysqli; +use Wikimedia\Rdbms\LBFactorySingle; +use Wikimedia\Rdbms\TransactionProfiler; +use Wikimedia\TestingAccessWrapper; +use Wikimedia\Rdbms\DatabaseSqlite; +use Wikimedia\Rdbms\DatabasePostgres; +use Wikimedia\Rdbms\DatabaseMssql; +use Wikimedia\Rdbms\DBUnexpectedError; + +class DatabaseTest extends PHPUnit\Framework\TestCase { + /** @var DatabaseTestHelper */ + private $db; + + use MediaWikiCoversValidator; + + protected function setUp() { + $this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() ); + } + + /** + * @dataProvider provideAddQuotes + * @covers Wikimedia\Rdbms\Database::factory + */ + public function testFactory() { + $m = Database::NEW_UNCONNECTED; // no-connect mode + $p = [ 'host' => 'localhost', 'user' => 'me', 'password' => 'myself', 'dbname' => 'i' ]; + + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'mysqli', $p, $m ) ); + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySqli', $p, $m ) ); + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySQLi', $p, $m ) ); + $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'postgres', $p, $m ) ); + $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'Postgres', $p, $m ) ); + + $x = $p + [ 'port' => 10000, 'UseWindowsAuth' => false ]; + $this->assertInstanceOf( DatabaseMssql::class, Database::factory( 'mssql', $x, $m ) ); + + $x = $p + [ 'dbFilePath' => 'some/file.sqlite' ]; + $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); + $x = $p + [ 'dbDirectory' => 'some/file' ]; + $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); + } + + public static function provideAddQuotes() { + return [ + [ null, 'NULL' ], + [ 1234, "'1234'" ], + [ 1234.5678, "'1234.5678'" ], + [ 'string', "'string'" ], + [ 'string\'s cause trouble', "'string\'s cause trouble'" ], + ]; + } + + /** + * @dataProvider provideAddQuotes + * @covers Wikimedia\Rdbms\Database::addQuotes + */ + public function testAddQuotes( $input, $expected ) { + $this->assertEquals( $expected, $this->db->addQuotes( $input ) ); + } + + public static function provideTableName() { + // Formatting is mostly ignored since addIdentifierQuotes is abstract. + // For testing of addIdentifierQuotes, see actual Database subclas tests. + return [ + 'local' => [ + 'tablename', + 'tablename', + 'quoted', + ], + 'local-raw' => [ + 'tablename', + 'tablename', + 'raw', + ], + 'shared' => [ + 'sharedb.tablename', + 'tablename', + 'quoted', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], + ], + 'shared-raw' => [ + 'sharedb.tablename', + 'tablename', + 'raw', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], + ], + 'shared-prefix' => [ + 'sharedb.sh_tablename', + 'tablename', + 'quoted', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], + ], + 'shared-prefix-raw' => [ + 'sharedb.sh_tablename', + 'tablename', + 'raw', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], + ], + 'foreign' => [ + 'databasename.tablename', + 'databasename.tablename', + 'quoted', + ], + 'foreign-raw' => [ + 'databasename.tablename', + 'databasename.tablename', + 'raw', + ], + ]; + } + + /** + * @dataProvider provideTableName + * @covers Wikimedia\Rdbms\Database::tableName + */ + public function testTableName( $expected, $table, $format, array $alias = null ) { + if ( $alias ) { + $this->db->setTableAliases( [ $table => $alias ] ); + } + $this->assertEquals( + $expected, + $this->db->tableName( $table, $format ?: 'quoted' ) + ); + } + + public function provideTableNamesWithIndexClauseOrJOIN() { + return [ + 'one-element array' => [ + [ 'table' ], [], 'table ' + ], + 'comma join' => [ + [ 'table1', 'table2' ], [], 'table1,table2 ' + ], + 'real join' => [ + [ 'table1', 'table2' ], + [ 'table2' => [ 'LEFT JOIN', 't1_id = t2_id' ] ], + 'table1 LEFT JOIN table2 ON ((t1_id = t2_id))' + ], + 'real join with multiple conditionals' => [ + [ 'table1', 'table2' ], + [ 'table2' => [ 'LEFT JOIN', [ 't1_id = t2_id', 't2_x = \'X\'' ] ] ], + 'table1 LEFT JOIN table2 ON ((t1_id = t2_id) AND (t2_x = \'X\'))' + ], + 'join with parenthesized group' => [ + [ 'table1', 'n' => [ 'table2', 'table3' ] ], + [ + 'table3' => [ 'JOIN', 't2_id = t3_id' ], + 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], + ], + 'table1 LEFT JOIN (table2 JOIN table3 ON ((t2_id = t3_id))) ON ((t1_id = t2_id))' + ], + 'join with degenerate parenthesized group' => [ + [ 'table1', 'n' => [ 't2' => 'table2' ] ], + [ + 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], + ], + 'table1 LEFT JOIN table2 t2 ON ((t1_id = t2_id))' + ], + ]; + } + + /** + * @dataProvider provideTableNamesWithIndexClauseOrJOIN + * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN + */ + public function testTableNamesWithIndexClauseOrJOIN( $tables, $join_conds, $expect ) { + $clause = TestingAccessWrapper::newFromObject( $this->db ) + ->tableNamesWithIndexClauseOrJOIN( $tables, [], [], $join_conds ); + $this->assertSame( $expect, $clause ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionIdle() { + $db = $this->db; + + $db->clearFlag( DBO_TRX ); + $called = false; + $flagSet = null; + $callback = function ( $trigger, IDatabase $db ) use ( &$flagSet, &$called ) { + $called = true; + $flagSet = $db->getFlag( DBO_TRX ); + }; + + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); + + $flagSet = null; + $called = false; + $db->startAtomic( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Callback not reached during TRX' ); + $db->endAtomic( __METHOD__ ); + + $this->assertTrue( $called, 'Callback reached after COMMIT' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + + $db->clearFlag( DBO_TRX ); + $db->onTransactionCommitOrIdle( + function ( $trigger, IDatabase $db ) { + $db->setFlag( DBO_TRX ); + }, + __METHOD__ + ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionIdle_TRX() { + $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'ping' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( '' ); + $db->setFlag( DBO_TRX ); + + $lbFactory = LBFactorySingle::newFromConnection( $db ); + // Ask for the connection so that LB sets internal state + // about this connection being the master connection + $lb = $lbFactory->getMainLB(); + $conn = $lb->openConnection( $lb->getWriterIndex() ); + $this->assertSame( $db, $conn, 'Same DB instance' ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); + + $called = false; + $flagSet = null; + $callback = function () use ( $db, &$flagSet, &$called ) { + $called = true; + $flagSet = $db->getFlag( DBO_TRX ); + }; + + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertTrue( $called, 'Called when lb-transaction is committed' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->rollbackMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called in next round commit' ); + + $db->setFlag( DBO_TRX ); + try { + $db->onTransactionCommitOrIdle( function () { + throw new RuntimeException( 'test' ); + } ); + $this->fail( "Exception not thrown" ); + } catch ( RuntimeException $e ) { + $this->assertTrue( $db->getFlag( DBO_TRX ) ); + } + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks + */ + public function testTransactionPreCommitOrIdle() { + $db = $this->getMockDB( [ 'isOpen' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->clearFlag( DBO_TRX ); + + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX is not set' ); + + $called = false; + $db->onTransactionPreCommitOrIdle( + function ( IDatabase $db ) use ( &$called ) { + $called = true; + }, + __METHOD__ + ); + $this->assertTrue( $called, 'Called when idle' ); + + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionPreCommitOrIdle( + function ( IDatabase $db ) use ( &$called ) { + $called = true; + }, + __METHOD__ + ); + $this->assertFalse( $called, 'Not called when transaction is active' ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Called when transaction is committed' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks + */ + public function testTransactionPreCommitOrIdle_TRX() { + $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'ping' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( 'unittest' ); + $db->setFlag( DBO_TRX ); + + $lbFactory = LBFactorySingle::newFromConnection( $db ); + // Ask for the connection so that LB sets internal state + // about this connection being the master connection + $lb = $lbFactory->getMainLB(); + $conn = $lb->openConnection( $lb->getWriterIndex() ); + $this->assertSame( $db, $conn, 'Same DB instance' ); + + $this->assertFalse( $lb->hasMasterChanges() ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); + $called = false; + $callback = function ( IDatabase $db ) use ( &$called ) { + $called = true; + }; + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); + $called = false; + $lbFactory->commitMasterChanges(); + $this->assertFalse( $called ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertTrue( $called, 'Called when lb-transaction is committed' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->rollbackMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called in next round commit' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionResolution + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionResolution() { + $db = $this->db; + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function ( $trigger, IDatabase $db ) use ( &$called ) { + $called = true; + $db->setFlag( DBO_TRX ); + } ); + $db->commit( __METHOD__ ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function ( $trigger, IDatabase $db ) use ( &$called ) { + $called = true; + $db->setFlag( DBO_TRX ); + } ); + $db->rollback( __METHOD__ ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setTransactionListener + */ + public function testTransactionListener() { + $db = $this->db; + + $db->setTransactionListener( 'ping', function () use ( $db, &$called ) { + $called = true; + } ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback still reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->rollback( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->setTransactionListener( 'ping', null ); + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertFalse( $called, 'Callback not reached' ); + } + + /** + * Use this mock instead of DatabaseTestHelper for cases where + * DatabaseTestHelper is too inflexibile due to mocking too much + * or being too restrictive about fname matching (e.g. for tests + * that assert behaviour when the name is a mismatch, we need to + * catch the error here instead of there). + * + * @return Database + */ + private function getMockDB( $methods = [] ) { + static $abstractMethods = [ + 'fetchAffectedRowCount', + 'closeConnection', + 'dataSeek', + 'doQuery', + 'fetchObject', 'fetchRow', + 'fieldInfo', 'fieldName', + 'getSoftwareLink', 'getServerVersion', + 'getType', + 'indexInfo', + 'insertId', + 'lastError', 'lastErrno', + 'numFields', 'numRows', + 'open', + 'strencode', + 'tableExists' + ]; + $db = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->setMethods( array_values( array_unique( array_merge( + $abstractMethods, + $methods + ) ) ) ) + ->getMock(); + $wdb = TestingAccessWrapper::newFromObject( $db ); + $wdb->trxProfiler = new TransactionProfiler(); + $wdb->connLogger = new \Psr\Log\NullLogger(); + $wdb->queryLogger = new \Psr\Log\NullLogger(); + $wdb->currentDomain = DatabaseDomain::newUnspecified(); + return $db; + } + + /** + * @covers Wikimedia\Rdbms\Database::flushSnapshot + */ + public function testFlushSnapshot() { + $db = $this->getMockDB( [ 'isOpen' ] ); + $db->method( 'isOpen' )->willReturn( true ); + + $db->flushSnapshot( __METHOD__ ); // ok + $db->flushSnapshot( __METHOD__ ); // ok + + $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $db->query( 'SELECT 1', __METHOD__ ); + $this->assertTrue( (bool)$db->trxLevel(), "Transaction started." ); + $db->flushSnapshot( __METHOD__ ); // ok + $db->restoreFlags( $db::RESTORE_PRIOR ); + + $this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." ); + } + + /** + * @covers Wikimedia\Rdbms\Database::getScopedLockAndFlush + * @covers Wikimedia\Rdbms\Database::lock + * @covers Wikimedia\Rdbms\Database::unlock + * @covers Wikimedia\Rdbms\Database::lockIsFree + */ + public function testGetScopedLock() { + $db = $this->getMockDB( [ 'isOpen', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( 'unittest' ); + + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) ); + $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( 0, $db->trxLevel() ); + + $db->setFlag( DBO_TRX ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) ); + $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $db->clearFlag( DBO_TRX ); + + // Pending writes with DBO_TRX + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) ); + $db->setFlag( DBO_TRX ); + $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock + try { + $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // Pending writes without DBO_TRX + $db->clearFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ) ); + $db->begin( __METHOD__ ); + $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock + try { + $lock = $db->getScopedLockAndFlush( 'meow2', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__ ); + // No pending writes, with DBO_TRX + $db->setFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'wuff', __METHOD__ ) ); + $db->query( "SELECT 1", __METHOD__ ); + $this->assertEquals( 1, $db->trxLevel() ); + $lock = $db->getScopedLockAndFlush( 'wuff', __METHOD__, 1 ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertFalse( $db->lockIsFree( 'wuff', __METHOD__ ), 'Lock already acquired' ); + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // No pending writes, without DBO_TRX + $db->clearFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'wuff2', __METHOD__ ) ); + $db->begin( __METHOD__ ); + try { + $lock = $db->getScopedLockAndFlush( 'wuff2', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertFalse( $db->lockIsFree( 'wuff2', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__ ); + } + + /** + * @covers Wikimedia\Rdbms\Database::getFlag + * @covers Wikimedia\Rdbms\Database::setFlag + * @covers Wikimedia\Rdbms\Database::restoreFlags + */ + public function testFlagSetting() { + $db = $this->db; + $origTrx = $db->getFlag( DBO_TRX ); + $origSsl = $db->getFlag( DBO_SSL ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origSsl, $db->getFlag( DBO_SSL ) ); + + $db->restoreFlags( $db::RESTORE_INITIAL ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + } + + /** + * @expectedException UnexpectedValueException + * @covers Wikimedia\Rdbms\Database::setFlag + */ + public function testDBOIgnoreSet() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $db->setFlag( Database::DBO_IGNORE ); + } + + /** + * @expectedException UnexpectedValueException + * @covers Wikimedia\Rdbms\Database::clearFlag + */ + public function testDBOIgnoreClear() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $db->clearFlag( Database::DBO_IGNORE ); + } + + /** + * @covers Wikimedia\Rdbms\Database::tablePrefix + * @covers Wikimedia\Rdbms\Database::dbSchema + */ + public function testSchemaAndPrefixMutators() { + $ud = DatabaseDomain::newUnspecified(); + + $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); + + $old = $this->db->tablePrefix(); + $oldDomain = $this->db->getDomainId(); + $this->assertInternalType( 'string', $old, 'Prefix is string' ); + $this->assertSame( $old, $this->db->tablePrefix(), "Prefix unchanged" ); + $this->assertSame( $old, $this->db->tablePrefix( 'xxx_' ) ); + $this->assertSame( 'xxx_', $this->db->tablePrefix(), "Prefix set" ); + $this->db->tablePrefix( $old ); + $this->assertNotEquals( 'xxx_', $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + + $old = $this->db->dbSchema(); + $oldDomain = $this->db->getDomainId(); + $this->assertInternalType( 'string', $old, 'Schema is string' ); + $this->assertSame( $old, $this->db->dbSchema(), "Schema unchanged" ); + + $this->db->selectDB( 'y' ); + $this->assertSame( $old, $this->db->dbSchema( 'xxx' ) ); + $this->assertSame( 'xxx', $this->db->dbSchema(), "Schema set" ); + $this->db->dbSchema( $old ); + $this->assertNotEquals( 'xxx', $this->db->dbSchema() ); + $this->assertSame( "y", $this->db->getDomainId() ); + } + + /** + * @covers Wikimedia\Rdbms\Database::tablePrefix + * @covers Wikimedia\Rdbms\Database::dbSchema + * @expectedException DBUnexpectedError + */ + public function testSchemaWithNoDB() { + $ud = DatabaseDomain::newUnspecified(); + + $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); + $this->assertSame( '', $this->db->dbSchema() ); + + $this->db->dbSchema( 'xxx' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::selectDomain + */ + public function testSelectDomain() { + $oldDomain = $this->db->getDomainId(); + $oldDatabase = $this->db->getDBname(); + $oldSchema = $this->db->dbSchema(); + $oldPrefix = $this->db->tablePrefix(); + + $this->db->selectDomain( 'testselectdb-xxx_' ); + $this->assertSame( 'testselectdb', $this->db->getDBname() ); + $this->assertSame( '', $this->db->dbSchema() ); + $this->assertSame( 'xxx_', $this->db->tablePrefix() ); + + $this->db->selectDomain( $oldDomain ); + $this->assertSame( $oldDatabase, $this->db->getDBname() ); + $this->assertSame( $oldSchema, $this->db->dbSchema() ); + $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + + $this->db->selectDomain( 'testselectdb-schema-xxx_' ); + $this->assertSame( 'testselectdb', $this->db->getDBname() ); + $this->assertSame( 'schema', $this->db->dbSchema() ); + $this->assertSame( 'xxx_', $this->db->tablePrefix() ); + + $this->db->selectDomain( $oldDomain ); + $this->assertSame( $oldDatabase, $this->db->getDBname() ); + $this->assertSame( $oldSchema, $this->db->dbSchema() ); + $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + } + +} diff --git a/tests/phpunit/includes/libs/services/ServiceContainerTest.php b/tests/phpunit/includes/libs/services/ServiceContainerTest.php new file mode 100644 index 000000000000..6e51883cfb2a --- /dev/null +++ b/tests/phpunit/includes/libs/services/ServiceContainerTest.php @@ -0,0 +1,497 @@ +<?php + +use Wikimedia\Services\ServiceContainer; + +/** + * @covers Wikimedia\Services\ServiceContainer + */ +class ServiceContainerTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; // TODO this library is supposed to be independent of MediaWiki + use PHPUnit4And6Compat; + + private function newServiceContainer( $extraArgs = [] ) { + return new ServiceContainer( $extraArgs ); + } + + public function testGetServiceNames() { + $services = $this->newServiceContainer(); + $names = $services->getServiceNames(); + + $this->assertInternalType( 'array', $names ); + $this->assertEmpty( $names ); + + $name = 'TestService92834576'; + $services->defineService( $name, function () { + return null; + } ); + + $names = $services->getServiceNames(); + $this->assertContains( $name, $names ); + } + + public function testHasService() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + $this->assertFalse( $services->hasService( $name ) ); + + $services->defineService( $name, function () { + return null; + } ); + + $this->assertTrue( $services->hasService( $name ) ); + } + + public function testGetService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + $count = 0; + + $services->defineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService, &$count ) { + $count++; + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( $extra, 'Foo' ); + return $theService; + } + ); + + $this->assertSame( $theService, $services->getService( $name ) ); + + $services->getService( $name ); + $this->assertSame( 1, $count, 'instantiator should be called exactly once!' ); + } + + public function testGetService_fail_unknown() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->getService( $name ); + } + + public function testPeekService() { + $services = $this->newServiceContainer(); + + $services->defineService( + 'Foo', + function () { + return new stdClass(); + } + ); + + $services->defineService( + 'Bar', + function () { + return new stdClass(); + } + ); + + // trigger instantiation of Foo + $services->getService( 'Foo' ); + + $this->assertInternalType( + 'object', + $services->peekService( 'Foo' ), + 'Peek should return the service object if it had been accessed before.' + ); + + $this->assertNull( + $services->peekService( 'Bar' ), + 'Peek should return null if the service was never accessed.' + ); + } + + public function testPeekService_fail_unknown() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->peekService( $name ); + } + + public function testDefineService() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function ( $actualLocator ) use ( $services, $theService ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + return $theService; + } ); + + $this->assertTrue( $services->hasService( $name ) ); + $this->assertSame( $theService, $services->getService( $name ) ); + } + + public function testDefineService_fail_duplicate() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + + $this->setExpectedException( Wikimedia\Services\ServiceAlreadyDefinedException::class ); + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testApplyWiring() { + $services = $this->newServiceContainer(); + + $wiring = [ + 'Foo' => function () { + return 'Foo!'; + }, + 'Bar' => function () { + return 'Bar!'; + }, + ]; + + $services->applyWiring( $wiring ); + + $this->assertSame( 'Foo!', $services->getService( 'Foo' ) ); + $this->assertSame( 'Bar!', $services->getService( 'Bar' ) ); + } + + public function testImportWiring() { + $services = $this->newServiceContainer(); + + $wiring = [ + 'Foo' => function () { + return 'Foo!'; + }, + 'Bar' => function () { + return 'Bar!'; + }, + 'Car' => function () { + return 'FUBAR!'; + }, + ]; + + $services->applyWiring( $wiring ); + + $services->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+X'; + } ); + + $services->addServiceManipulator( 'Car', function ( $service ) { + return $service . '+X'; + } ); + + $newServices = $this->newServiceContainer(); + + // create a service with manipulator + $newServices->defineService( 'Foo', function () { + return 'Foo!'; + } ); + + $newServices->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+Y'; + } ); + + // create a service before importing, so we can later check that + // existing service instances survive importWiring() + $newServices->defineService( 'Car', function () { + return 'Car!'; + } ); + + // force instantiation + $newServices->getService( 'Car' ); + + // Define another service, so we can later check that extra wiring + // is not lost. + $newServices->defineService( 'Xar', function () { + return 'Xar!'; + } ); + + // import wiring, but skip `Bar` + $newServices->importWiring( $services, [ 'Bar' ] ); + + $this->assertNotContains( 'Bar', $newServices->getServiceNames(), 'Skip `Bar` service' ); + $this->assertSame( 'Foo!+Y+X', $newServices->getService( 'Foo' ) ); + + // import all wiring, but preserve existing service instance + $newServices->importWiring( $services ); + + $this->assertContains( 'Bar', $newServices->getServiceNames(), 'Import all services' ); + $this->assertSame( 'Bar!', $newServices->getService( 'Bar' ) ); + $this->assertSame( 'Car!', $newServices->getService( 'Car' ), 'Use existing service instance' ); + $this->assertSame( 'Xar!', $newServices->getService( 'Xar' ), 'Predefined services are kept' ); + } + + public function testLoadWiringFiles() { + $services = $this->newServiceContainer(); + + $wiringFiles = [ + __DIR__ . '/TestWiring1.php', + __DIR__ . '/TestWiring2.php', + ]; + + $services->loadWiringFiles( $wiringFiles ); + + $this->assertSame( 'Foo!', $services->getService( 'Foo' ) ); + $this->assertSame( 'Bar!', $services->getService( 'Bar' ) ); + } + + public function testLoadWiringFiles_fail_duplicate() { + $services = $this->newServiceContainer(); + + $wiringFiles = [ + __DIR__ . '/TestWiring1.php', + __DIR__ . '/./TestWiring1.php', + ]; + + // loading the same file twice should fail, because + $this->setExpectedException( Wikimedia\Services\ServiceAlreadyDefinedException::class ); + + $services->loadWiringFiles( $wiringFiles ); + } + + public function testRedefineService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + PHPUnit_Framework_Assert::fail( + 'The original instantiator function should not get called' + ); + } ); + + // redefine before instantiation + $services->redefineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService1 ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService1; + } + ); + + // force instantiation, check result + $this->assertSame( $theService1, $services->getService( $name ) ); + } + + public function testRedefineService_disabled() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + return 'Foo'; + } ); + + // disable the service. we should be able to redefine it anyway. + $services->disableService( $name ); + + $services->redefineService( $name, function () use ( $theService1 ) { + return $theService1; + } ); + + // force instantiation, check result + $this->assertSame( $theService1, $services->getService( $name ) ); + } + + public function testRedefineService_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testRedefineService_fail_in_use() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + return 'Foo'; + } ); + + // create the service, so it can no longer be redefined + $services->getService( $name ); + + $this->setExpectedException( Wikimedia\Services\CannotReplaceActiveServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testAddServiceManipulator() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $theService2 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService1 ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService1; + } + ); + + $services->addServiceManipulator( + $name, + function ( + $theService, $actualLocator, $extra + ) use ( + $services, $theService1, $theService2 + ) { + PHPUnit_Framework_Assert::assertSame( $theService1, $theService ); + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService2; + } + ); + + // force instantiation, check result + $this->assertSame( $theService2, $services->getService( $name ) ); + } + + public function testAddServiceManipulator_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->addServiceManipulator( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testAddServiceManipulator_fail_in_use() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + + // create the service, so it can no longer be redefined + $services->getService( $name ); + + $this->setExpectedException( Wikimedia\Services\CannotReplaceActiveServiceException::class ); + + $services->addServiceManipulator( $name, function () { + return 'Foo'; + } ); + } + + public function testDisableService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $destructible = $this->getMockBuilder( Wikimedia\Services\DestructibleService::class ) + ->getMock(); + $destructible->expects( $this->once() ) + ->method( 'destroy' ); + + $services->defineService( 'Foo', function () use ( $destructible ) { + return $destructible; + } ); + $services->defineService( 'Bar', function () { + return new stdClass(); + } ); + $services->defineService( 'Qux', function () { + return new stdClass(); + } ); + + // instantiate Foo and Bar services + $services->getService( 'Foo' ); + $services->getService( 'Bar' ); + + // disable service, should call destroy() once. + $services->disableService( 'Foo' ); + + // disabled service should still be listed + $this->assertContains( 'Foo', $services->getServiceNames() ); + + // getting other services should still work + $services->getService( 'Bar' ); + + // disable non-destructible service, and not-yet-instantiated service + $services->disableService( 'Bar' ); + $services->disableService( 'Qux' ); + + $this->assertNull( $services->peekService( 'Bar' ) ); + $this->assertNull( $services->peekService( 'Qux' ) ); + + // disabled service should still be listed + $this->assertContains( 'Bar', $services->getServiceNames() ); + $this->assertContains( 'Qux', $services->getServiceNames() ); + + $this->setExpectedException( Wikimedia\Services\ServiceDisabledException::class ); + $services->getService( 'Qux' ); + } + + public function testDisableService_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testDestroy() { + $services = $this->newServiceContainer(); + + $destructible = $this->getMockBuilder( Wikimedia\Services\DestructibleService::class ) + ->getMock(); + $destructible->expects( $this->once() ) + ->method( 'destroy' ); + + $services->defineService( 'Foo', function () use ( $destructible ) { + return $destructible; + } ); + + $services->defineService( 'Bar', function () { + return new stdClass(); + } ); + + // create the service + $services->getService( 'Foo' ); + + // destroy the container + $services->destroy(); + + $this->setExpectedException( Wikimedia\Services\ContainerDisabledException::class ); + $services->getService( 'Bar' ); + } + +} diff --git a/tests/phpunit/includes/libs/services/TestWiring1.php b/tests/phpunit/includes/libs/services/TestWiring1.php new file mode 100644 index 000000000000..b6ff4eb3b423 --- /dev/null +++ b/tests/phpunit/includes/libs/services/TestWiring1.php @@ -0,0 +1,10 @@ +<?php +/** + * Test file for testing ServiceContainer::loadWiringFiles + */ + +return [ + 'Foo' => function () { + return 'Foo!'; + }, +]; diff --git a/tests/phpunit/includes/libs/services/TestWiring2.php b/tests/phpunit/includes/libs/services/TestWiring2.php new file mode 100644 index 000000000000..dfff64f04889 --- /dev/null +++ b/tests/phpunit/includes/libs/services/TestWiring2.php @@ -0,0 +1,10 @@ +<?php +/** + * Test file for testing ServiceContainer::loadWiringFiles + */ + +return [ + 'Bar' => function () { + return 'Bar!'; + }, +]; diff --git a/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php b/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php new file mode 100644 index 000000000000..46e23e36db71 --- /dev/null +++ b/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php @@ -0,0 +1,58 @@ +<?php + +use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; + +/** + * @covers PrefixingStatsdDataFactoryProxy + */ +class PrefixingStatsdDataFactoryProxyTest extends PHPUnit\Framework\TestCase { + + use PHPUnit4And6Compat; + + public function provideMethodNames() { + return [ + [ 'timing' ], + [ 'gauge' ], + [ 'set' ], + [ 'increment' ], + [ 'decrement' ], + [ 'updateCount' ], + [ 'produceStatsdData' ], + ]; + } + + /** + * @dataProvider provideMethodNames + */ + public function testPrefixingAndPassthrough( $method ) { + /** @var StatsdDataFactoryInterface|PHPUnit_Framework_MockObject_MockObject $innerFactory */ + $innerFactory = $this->getMock( + \Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface::class + ); + $innerFactory->expects( $this->once() ) + ->method( $method ) + ->with( 'testprefix.metricname' ); + + $proxy = new PrefixingStatsdDataFactoryProxy( $innerFactory, 'testprefix' ); + // 1,2,3,4 simply makes sure we provide enough parameters, without caring what they are + $proxy->$method( 'metricname', 1, 2, 3, 4 ); + } + + /** + * @dataProvider provideMethodNames + */ + public function testPrefixIsTrimmed( $method ) { + /** @var StatsdDataFactoryInterface|PHPUnit_Framework_MockObject_MockObject $innerFactory */ + $innerFactory = $this->getMock( + \Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface::class + ); + $innerFactory->expects( $this->once() ) + ->method( $method ) + ->with( 'testprefix.metricname' ); + + $proxy = new PrefixingStatsdDataFactoryProxy( $innerFactory, 'testprefix...' ); + // 1,2,3,4 simply makes sure we provide enough parameters, without caring what they are + $proxy->$method( 'metricname', 1, 2, 3, 4 ); + } + +} |