aboutsummaryrefslogtreecommitdiffstats
path: root/tests/phpunit/includes/libs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/phpunit/includes/libs')
-rw-r--r--tests/phpunit/includes/libs/ArrayUtilsTest.php308
-rw-r--r--tests/phpunit/includes/libs/CookieTest.php52
-rw-r--r--tests/phpunit/includes/libs/DeferredStringifierTest.php54
-rw-r--r--tests/phpunit/includes/libs/DnsSrvDiscovererTest.php144
-rw-r--r--tests/phpunit/includes/libs/EasyDeflateTest.php64
-rw-r--r--tests/phpunit/includes/libs/GenericArrayObjectTest.php279
-rw-r--r--tests/phpunit/includes/libs/HashRingTest.php327
-rw-r--r--tests/phpunit/includes/libs/HtmlArmorTest.php55
-rw-r--r--tests/phpunit/includes/libs/IEUrlExtensionTest.php42
-rw-r--r--tests/phpunit/includes/libs/IPTest.php673
-rw-r--r--tests/phpunit/includes/libs/JavaScriptMinifierTest.php367
-rw-r--r--tests/phpunit/includes/libs/MapCacheLRUTest.php267
-rw-r--r--tests/phpunit/includes/libs/MemoizedCallableTest.php142
-rw-r--r--tests/phpunit/includes/libs/ProcessCacheLRUTest.php264
-rw-r--r--tests/phpunit/includes/libs/SamplingStatsdClientTest.php77
-rw-r--r--tests/phpunit/includes/libs/StaticArrayWriterTest.php58
-rw-r--r--tests/phpunit/includes/libs/StringUtilsTest.php128
-rw-r--r--tests/phpunit/includes/libs/TimingTest.php115
-rw-r--r--tests/phpunit/includes/libs/XhprofDataTest.php274
-rw-r--r--tests/phpunit/includes/libs/XhprofTest.php113
-rw-r--r--tests/phpunit/includes/libs/XmlTypeCheckTest.php79
-rw-r--r--tests/phpunit/includes/libs/composer/ComposerInstalledTest.php498
-rw-r--r--tests/phpunit/includes/libs/composer/ComposerJsonTest.php41
-rw-r--r--tests/phpunit/includes/libs/composer/ComposerLockTest.php120
-rw-r--r--tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php150
-rw-r--r--tests/phpunit/includes/libs/http/HttpAcceptParserTest.php56
-rw-r--r--tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php60
-rw-r--r--tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php140
-rw-r--r--tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php159
-rw-r--r--tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php163
-rw-r--r--tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php62
-rw-r--r--tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php1867
-rw-r--r--tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php81
-rw-r--r--tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php147
-rw-r--r--tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php139
-rw-r--r--tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php108
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php223
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php226
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php62
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php740
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php2164
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php60
-rw-r--r--tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php707
-rw-r--r--tests/phpunit/includes/libs/services/ServiceContainerTest.php497
-rw-r--r--tests/phpunit/includes/libs/services/TestWiring1.php10
-rw-r--r--tests/phpunit/includes/libs/services/TestWiring2.php10
-rw-r--r--tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php58
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>',
+ '&lt;script&gt;alert(&quot;evil!&quot;);&lt;/script&gt;',
+ ],
+ [
+ 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 );
+ }
+
+}