summaryrefslogtreecommitdiff
path: root/www/wiki/tests/phpunit/includes/libs
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/tests/phpunit/includes/libs')
-rw-r--r--www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php310
-rw-r--r--www/wiki/tests/phpunit/includes/libs/CSSMinTest.php640
-rw-r--r--www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php54
-rw-r--r--www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php144
-rw-r--r--www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php279
-rw-r--r--www/wiki/tests/phpunit/includes/libs/HashRingTest.php59
-rw-r--r--www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php209
-rw-r--r--www/wiki/tests/phpunit/includes/libs/IPTest.php672
-rw-r--r--www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php242
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php142
-rw-r--r--www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php268
-rw-r--r--www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php77
-rw-r--r--www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php128
-rw-r--r--www/wiki/tests/phpunit/includes/libs/TimingTest.php115
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php278
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XhprofTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php79
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php499
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php42
-rw-r--r--www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php121
-rw-r--r--www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php150
-rw-r--r--www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php56
-rw-r--r--www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php131
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php300
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php158
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php163
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php140
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php62
-rw-r--r--www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php1711
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php147
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php139
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php108
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php133
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php55
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php743
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php2067
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php60
-rw-r--r--www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php613
-rw-r--r--www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php227
-rw-r--r--www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php55
44 files changed, 12013 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php b/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php
new file mode 100644
index 00000000..d5ac77bb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/ArrayUtilsTest.php
@@ -0,0 +1,310 @@
+<?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 = func_get_args();
+ array_shift( $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/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php b/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php
new file mode 100644
index 00000000..46bf2c6c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/CSSMinTest.php
@@ -0,0 +1,640 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ * @group CSSMin
+ */
+class CSSMinTest extends MediaWikiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // For wfExpandUrl
+ $server = 'https://expand.example';
+ $this->setMwGlobals( [
+ 'wgServer' => $server,
+ 'wgCanonicalServer' => $server,
+ ] );
+ }
+
+ /**
+ * @dataProvider provideSerializeStringValue
+ * @covers CSSMin::serializeStringValue
+ */
+ public function testSerializeStringValue( $input, $expected ) {
+ $output = CSSMin::serializeStringValue( $input );
+ $this->assertEquals(
+ $expected,
+ $output,
+ 'Serialized output must be in the expected form.'
+ );
+ }
+
+ public static function provideSerializeStringValue() {
+ return [
+ [ 'Hello World!', '"Hello World!"' ],
+ [ "Null\0Null", "\"Null\\fffd Null\"" ],
+ [ '"', '"\\""' ],
+ [ "'", '"\'"' ],
+ [ "\\", '"\\\\"' ],
+ [ "Tab\tTab", '"Tab\\9 Tab"' ],
+ [ "Space tab \t space", '"Space tab \\9 space"' ],
+ [ "Line\nfeed", '"Line\\a feed"' ],
+ [ "Return\rreturn", '"Return\\d return"' ],
+ [ "Next\xc2\x85line", "\"Next\xc2\x85line\"" ],
+ [ "Del\x7fDel", '"Del\\7f Del"' ],
+ [ "nb\xc2\xa0sp", "\"nb\xc2\xa0sp\"" ],
+ [ "AMP&amp;AMP", "\"AMP&amp;AMP\"" ],
+ [ '!"#$%&\'()*+,-./0123456789:;<=>?', '"!\\"#$%&\'()*+,-./0123456789:;<=>?"' ],
+ [ '@[\\]^_`{|}~', '"@[\\\\]^_`{|}~"' ],
+ [ 'ä', '"ä"' ],
+ [ 'Ä', '"Ä"' ],
+ [ '€', '"€"' ],
+ [ '𝒞', '"𝒞"' ], // U+1D49E 'MATHEMATICAL SCRIPT CAPITAL C'
+ ];
+ }
+
+ /**
+ * @dataProvider provideMimeType
+ * @covers CSSMin::getMimeType
+ */
+ public function testGetMimeType( $fileContents, $fileExtension, $expected ) {
+ $fileName = wfTempDir() . DIRECTORY_SEPARATOR . uniqid( 'MW_PHPUnit_CSSMinTest_' ) . '.'
+ . $fileExtension;
+ $this->addTmpFiles( $fileName );
+ file_put_contents( $fileName, $fileContents );
+ $this->assertSame( $expected, CSSMin::getMimeType( $fileName ) );
+ }
+
+ public static function provideMimeType() {
+ return [
+ 'JPEG with short extension' => [
+ "\xFF\xD8\xFF",
+ 'jpg',
+ 'image/jpeg'
+ ],
+ 'JPEG with long extension' => [
+ "\xFF\xD8\xFF",
+ 'jpeg',
+ 'image/jpeg'
+ ],
+ 'PNG' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'png',
+ 'image/png'
+ ],
+
+ 'PNG extension but JPEG content' => [
+ "\xFF\xD8\xFF",
+ 'png',
+ 'image/png'
+ ],
+ 'JPEG extension but PNG content' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'jpg',
+ 'image/jpeg'
+ ],
+ 'PNG extension but SVG content' => [
+ '<?xml version="1.0"?><svg></svg>',
+ 'png',
+ 'image/png'
+ ],
+ 'SVG extension but PNG content' => [
+ "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A",
+ 'svg',
+ 'image/svg+xml'
+ ],
+
+ 'SVG with all headers' => [
+ '<?xml version="1.0"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" '
+ . '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG with XML header only' => [
+ '<?xml version="1.0"?><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG with DOCTYPE only' => [
+ '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" '
+ . '"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ 'SVG without any header' => [
+ '<svg></svg>',
+ 'svg',
+ 'image/svg+xml'
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideMinifyCases
+ * @covers CSSMin::minify
+ */
+ public function testMinify( $code, $expectedOutput ) {
+ $minified = CSSMin::minify( $code );
+
+ $this->assertEquals(
+ $expectedOutput,
+ $minified,
+ 'Minified output should be in the form expected.'
+ );
+ }
+
+ public static function provideMinifyCases() {
+ return [
+ // Whitespace
+ [ "\r\t\f \v\n\r", "" ],
+ [ "foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+
+ // Loose comments
+ [ "/* foo */", "" ],
+ [ "/*******\n foo\n *******/", "" ],
+ [ "/*!\n foo\n */", "" ],
+
+ // Inline comments in various different places
+ [ "/* comment */foo, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo/* comment */, bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo,/* comment */ bar {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar/* comment */ {\n\tprop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\t/* comment */prop: value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\tprop: /* comment */value;\n}", "foo,bar{prop:value}" ],
+ [ "foo, bar {\n\tprop: value /* comment */;\n}", "foo,bar{prop:value }" ],
+ [ "foo, bar {\n\tprop: value; /* comment */\n}", "foo,bar{prop:value; }" ],
+
+ // Keep track of things that aren't as minified as much as they
+ // could be (T37493)
+ [ 'foo { prop: value ;}', 'foo{prop:value }' ],
+ [ 'foo { prop : value; }', 'foo{prop :value}' ],
+ [ 'foo { prop: value ; }', 'foo{prop:value }' ],
+ [ 'foo { font-family: "foo" , "bar"; }', 'foo{font-family:"foo" ,"bar"}' ],
+ [ "foo { src:\n\turl('foo') ,\n\turl('bar') ; }", "foo{src:url('foo') ,url('bar') }" ],
+
+ // Interesting cases with string values
+ // - Double quotes, single quotes
+ [ 'foo { content: ""; }', 'foo{content:""}' ],
+ [ "foo { content: ''; }", "foo{content:''}" ],
+ [ 'foo { content: "\'"; }', 'foo{content:"\'"}' ],
+ [ "foo { content: '\"'; }", "foo{content:'\"'}" ],
+ // - Whitespace in string values
+ [ 'foo { content: " "; }', 'foo{content:" "}' ],
+
+ // Whitespaces after opening and before closing parentheses and brackets
+ [ 'a:not( [ href ] ) { prop: url( foobar.png ); }', 'a:not([href]){prop:url(foobar.png)}' ],
+
+ // Ensure that the invalid "url (" will not become the valid "url(" by minification
+ [ 'foo { prop: url ( foobar.png ); }', 'foo{prop:url (foobar.png)}' ],
+ ];
+ }
+
+ public static function provideIsRemoteUrl() {
+ return [
+ [ true, 'http://localhost/w/red.gif?123' ],
+ [ true, 'https://example.org/x.png' ],
+ [ true, '//example.org/x.y.z/image.png' ],
+ [ true, '//localhost/styles.css?query=yes' ],
+ [ true, 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs=' ],
+ [ false, 'x.gif' ],
+ [ false, '/x.gif' ],
+ [ false, './x.gif' ],
+ [ false, '../x.gif' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsRemoteUrl
+ * @covers CSSMin::isRemoteUrl
+ */
+ public function testIsRemoteUrl( $expect, $url ) {
+ $class = TestingAccessWrapper::newFromClass( CSSMin::class );
+ $this->assertEquals( $class->isRemoteUrl( $url ), $expect );
+ }
+
+ public static function provideIsLocalUrls() {
+ return [
+ [ false, 'x.gif' ],
+ [ true, '/x.gif' ],
+ [ false, './x.gif' ],
+ [ false, '../x.gif' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsLocalUrls
+ * @covers CSSMin::isLocalUrl
+ */
+ public function testIsLocalUrl( $expect, $url ) {
+ $class = TestingAccessWrapper::newFromClass( CSSMin::class );
+ $this->assertEquals( $class->isLocalUrl( $url ), $expect );
+ }
+
+ /**
+ * This test tests funky parameters to CSSMin::remap.
+ *
+ * @see testRemapRemapping for testing of the basic functionality
+ * @dataProvider provideRemapCases
+ * @covers CSSMin::remap
+ * @covers CSSMin::remapOne
+ */
+ public function testRemap( $message, $params, $expectedOutput ) {
+ $remapped = call_user_func_array( 'CSSMin::remap', $params );
+
+ $messageAdd = " Case: $message";
+ $this->assertEquals(
+ $expectedOutput,
+ $remapped,
+ 'CSSMin::remap should return the expected url form.' . $messageAdd
+ );
+ }
+
+ public static function provideRemapCases() {
+ // Parameter signature:
+ // CSSMin::remap( $code, $local, $remote, $embedData = true )
+ return [
+ [
+ 'Simple case',
+ [ 'foo { prop: url(bar.png); }', false, 'http://example.org', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'Without trailing slash',
+ [ 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'With trailing slash on remote (T29052)',
+ [ 'foo { prop: url(../bar.png); }', false, 'http://example.org/quux/', false ],
+ 'foo { prop: url(http://example.org/bar.png); }',
+ ],
+ [
+ 'Guard against stripping double slashes from query',
+ [ 'foo { prop: url(bar.png?corge=//grault); }', false, 'http://example.org/quux/', false ],
+ 'foo { prop: url(http://example.org/quux/bar.png?corge=//grault); }',
+ ],
+ [
+ 'Expand absolute paths',
+ [ 'foo { prop: url(/w/skin/images/bar.png); }', false, 'http://example.org/quux', false ],
+ 'foo { prop: url(https://expand.example/w/skin/images/bar.png); }',
+ ],
+ [
+ "Don't barf at behavior: url(#default#behaviorName) - T162973",
+ [ 'foo { behavior: url(#default#bar); }', false, '/w/', false ],
+ 'foo { behavior: url("#default#bar"); }',
+ ],
+ ];
+ }
+
+ /**
+ * Cases with empty url() for CSSMin::remap.
+ *
+ * Regression test for T191237.
+ *
+ * @dataProvider provideRemapEmptyUrl
+ * @covers CSSMin
+ */
+ public function testRemapEmptyUrl( $params, $expected ) {
+ $remapped = call_user_func_array( 'CSSMin::remap', $params );
+ $this->assertEquals( $expected, $remapped, 'Ignore empty url' );
+ }
+
+ public static function provideRemapEmptyUrl() {
+ return [
+ 'Empty' => [
+ [ "background-image: url();", false, '/example', false ],
+ "background-image: url();",
+ ],
+ 'Single quote' => [
+ [ "background-image: url('');", false, '/example', false ],
+ "background-image: url('');",
+ ],
+ 'Double quote' => [
+ [ 'background-image: url("");', false, '/example', false ],
+ 'background-image: url("");',
+ ],
+ ];
+ }
+
+ /**
+ * This tests the basic functionality of CSSMin::remap.
+ *
+ * @see testRemap for testing of funky parameters
+ * @dataProvider provideRemapRemappingCases
+ * @covers CSSMin
+ */
+ public function testRemapRemapping( $message, $input, $expectedOutput ) {
+ $localPath = __DIR__ . '/../../data/cssmin';
+ $remotePath = 'http://localhost/w';
+
+ $realOutput = CSSMin::remap( $input, $localPath, $remotePath );
+ $this->assertEquals( $expectedOutput, $realOutput, "CSSMin::remap: $message" );
+ }
+
+ public static function provideRemapRemappingCases() {
+ // red.gif and green.gif are one-pixel 35-byte GIFs.
+ // large.png is a 35K PNG that should be non-embeddable.
+ // Full paths start with http://localhost/w/.
+ // Timestamps in output are replaced with 'timestamp'.
+
+ // data: URIs for red.gif, green.gif, circle.svg
+ $red = 'data:image/gif;base64,R0lGODlhAQABAIAAAP8AADAAACwAAAAAAQABAAACAkQBADs=';
+ $green = 'data:image/gif;base64,R0lGODlhAQABAIAAAACAADAAACwAAAAAAQABAAACAkQBADs=';
+ $svg = 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%228'
+ . '%22 height=%228%22 viewBox=%220 0 8 8%22%3E %3Ccircle cx=%224%22 cy=%224%22 '
+ . 'r=%222%22/%3E %3Ca xmlns:xlink=%22http://www.w3.org/1999/xlink%22 xlink:title='
+ . '%22%3F%3E%22%3Etest%3C/a%3E %3C/svg%3E';
+
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'Regular file',
+ 'foo { background: url(red.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ 'Regular file (missing)',
+ 'foo { background: url(theColorOfHerHair.gif); }',
+ 'foo { background: url(http://localhost/w/theColorOfHerHair.gif); }',
+ ],
+ [
+ 'Remote URL',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ ],
+ [
+ 'Protocol-relative remote URL',
+ 'foo { background: url(//example.org/w/foo.png); }',
+ 'foo { background: url(//example.org/w/foo.png); }',
+ ],
+ [
+ 'Remote URL with query',
+ 'foo { background: url(http://example.org/w/foo.png?query=yes); }',
+ 'foo { background: url(http://example.org/w/foo.png?query=yes); }',
+ ],
+ [
+ 'Protocol-relative remote URL with query',
+ 'foo { background: url(//example.org/w/foo.png?query=yes); }',
+ 'foo { background: url(//example.org/w/foo.png?query=yes); }',
+ ],
+ [
+ 'Domain-relative URL',
+ 'foo { background: url(/static/foo.png); }',
+ 'foo { background: url(https://expand.example/static/foo.png); }',
+ ],
+ [
+ 'Domain-relative URL with query',
+ 'foo { background: url(/static/foo.png?query=yes); }',
+ 'foo { background: url(https://expand.example/static/foo.png?query=yes); }',
+ ],
+ [
+ 'Remote URL (unnecessary quotes not preserved)',
+ 'foo { background: url("http://example.org/w/unnecessary-quotes.png"); }',
+ 'foo { background: url(http://example.org/w/unnecessary-quotes.png); }',
+ ],
+ [
+ 'Embedded file',
+ 'foo { /* @embed */ background: url(red.gif); }',
+ "foo { background: url($red); background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Embedded file, other comments before the rule',
+ "foo { /* Bar. */ /* @embed */ background: url(red.gif); }",
+ "foo { /* Bar. */ background: url($red); /* Bar. */ background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Can not re-embed data: URIs',
+ "foo { /* @embed */ background: url($red); }",
+ "foo { background: url($red); }",
+ ],
+ [
+ 'Can not remap data: URIs',
+ "foo { background: url($red); }",
+ "foo { background: url($red); }",
+ ],
+ [
+ 'Can not embed remote URLs',
+ 'foo { /* @embed */ background: url(http://example.org/w/foo.png); }',
+ 'foo { background: url(http://example.org/w/foo.png); }',
+ ],
+ [
+ 'Embedded file (inline @embed)',
+ 'foo { background: /* @embed */ url(red.gif); }',
+ "foo { background: url($red); "
+ . "background: url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Can not embed large files',
+ 'foo { /* @embed */ background: url(large.png); }',
+ "foo { background: url(http://localhost/w/large.png?e3d1f); }",
+ ],
+ [
+ 'SVG files are embedded without base64 encoding and unnecessary IE 6 and 7 fallback',
+ 'foo { /* @embed */ background: url(circle.svg); }',
+ "foo { background: url(\"$svg\"); }",
+ ],
+ [
+ 'Two regular files in one rule',
+ 'foo { background: url(red.gif), url(green.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6), '
+ . 'url(http://localhost/w/green.gif?13651); }',
+ ],
+ [
+ 'Two embedded files in one rule',
+ 'foo { /* @embed */ background: url(red.gif), url(green.gif); }',
+ "foo { background: url($red), url($green); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/green.gif?13651)!ie; }",
+ ],
+ [
+ 'Two embedded files in one rule (inline @embed)',
+ 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(green.gif); }',
+ "foo { background: url($red), url($green); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/green.gif?13651)!ie; }",
+ ],
+ [
+ 'Two embedded files in one rule (inline @embed), one too large',
+ 'foo { background: /* @embed */ url(red.gif), /* @embed */ url(large.png); }',
+ "foo { background: url($red), url(http://localhost/w/large.png?e3d1f); "
+ . "background: url(http://localhost/w/red.gif?34ac6), "
+ . "url(http://localhost/w/large.png?e3d1f)!ie; }",
+ ],
+ [
+ 'Practical example with some noise',
+ 'foo { /* @embed */ background: #f9f9f9 url(red.gif) 0 0 no-repeat; }',
+ "foo { background: #f9f9f9 url($red) 0 0 no-repeat; "
+ . "background: #f9f9f9 url(http://localhost/w/red.gif?34ac6) 0 0 no-repeat!ie; }",
+ ],
+ [
+ 'Does not mess with other properties',
+ 'foo { color: red; background: url(red.gif); font-size: small; }',
+ 'foo { color: red; background: url(http://localhost/w/red.gif?34ac6); font-size: small; }',
+ ],
+ [
+ 'Spacing and miscellanea not changed (1)',
+ 'foo { background: url(red.gif); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ 'Spacing and miscellanea not changed (2)',
+ 'foo {background:url(red.gif)}',
+ 'foo {background:url(http://localhost/w/red.gif?34ac6)}',
+ ],
+ [
+ 'Spaces within url() parentheses are ignored',
+ 'foo { background: url( red.gif ); }',
+ 'foo { background: url(http://localhost/w/red.gif?34ac6); }',
+ ],
+ [
+ '@import rule to local file (should we remap this?)',
+ '@import url(/styles.css)',
+ '@import url(https://expand.example/styles.css)',
+ ],
+ [
+ '@import rule to local file (should we remap this?)',
+ '@import url(/styles.css)',
+ '@import url(https://expand.example/styles.css)',
+ ],
+ [
+ '@import rule to URL',
+ '@import url(//localhost/styles.css?query=val)',
+ '@import url(//localhost/styles.css?query=val)',
+ ],
+ [
+ 'Background URL (double quotes)',
+ 'foo { background: url("//localhost/styles.css?quoted=double") }',
+ 'foo { background: url(//localhost/styles.css?quoted=double) }',
+ ],
+ [
+ 'Background URL (single quotes)',
+ 'foo { background: url(\'//localhost/styles.css?quoted=single\') }',
+ 'foo { background: url(//localhost/styles.css?quoted=single) }',
+ ],
+ [
+ 'Background URL (double quoted, containing parentheses; T60473)',
+ 'foo { background: url("//localhost/styles.css?query=(parens)") }',
+ 'foo { background: url("//localhost/styles.css?query=(parens)") }',
+ ],
+ [
+ 'Background URL (double quoted, containing single quotes; T60473)',
+ 'foo { background: url("//localhost/styles.css?quote=\'") }',
+ 'foo { background: url("//localhost/styles.css?quote=\'") }',
+ ],
+ [
+ 'Background URL (single quoted, containing double quotes; T60473)',
+ 'foo { background: url(\'//localhost/styles.css?quote="\') }',
+ 'foo { background: url("//localhost/styles.css?quote=\"") }',
+ ],
+ [
+ 'Background URL (double quoted with outer spacing)',
+ 'foo { background: url( "http://localhost/styles.css?quoted=double" ) }',
+ 'foo { background: url(http://localhost/styles.css?quoted=double) }',
+ ],
+ [
+ 'Simple case with comments before url',
+ 'foo { prop: /* some {funny;} comment */ url(bar.png); }',
+ 'foo { prop: /* some {funny;} comment */ url(http://localhost/w/bar.png); }',
+ ],
+ [
+ 'Simple case with comments after url',
+ 'foo { prop: url(red.gif)/* some {funny;} comment */ ; }',
+ 'foo { prop: url(http://localhost/w/red.gif?34ac6)/* some {funny;} comment */ ; }',
+ ],
+ [
+ 'Embedded file with comment before url',
+ 'foo { /* @embed */ background: /* some {funny;} comment */ url(red.gif); }',
+ "foo { background: /* some {funny;} comment */ url($red); background: /* some {funny;} comment */ url(http://localhost/w/red.gif?34ac6)!ie; }",
+ ],
+ [
+ 'Embedded file with comments inside and outside the rule',
+ 'foo { /* @embed */ background: url(red.gif) /* some {foo;} comment */; /* some {bar;} comment */ }',
+ "foo { background: url($red) /* some {foo;} comment */; background: url(http://localhost/w/red.gif?34ac6) /* some {foo;} comment */!ie; /* some {bar;} comment */ }",
+ ],
+ [
+ 'Embedded file with comment outside the rule',
+ 'foo { /* @embed */ background: url(red.gif); /* some {funny;} comment */ }',
+ "foo { background: url($red); background: url(http://localhost/w/red.gif?34ac6)!ie; /* some {funny;} comment */ }",
+ ],
+ [
+ 'Rule with two urls, each with comments',
+ '{ background: /*asd*/ url(something.png); background: /*jkl*/ url(something.png); }',
+ '{ background: /*asd*/ url(http://localhost/w/something.png); background: /*jkl*/ url(http://localhost/w/something.png); }',
+ ],
+ [
+ 'Sanity check for offending line from jquery.ui.theme.css (T62077)',
+ '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }',
+ '.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(http://localhost/w/images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }',
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * This tests basic functionality of CSSMin::buildUrlValue.
+ *
+ * @dataProvider provideBuildUrlValueCases
+ * @covers CSSMin::buildUrlValue
+ */
+ public function testBuildUrlValue( $message, $input, $expectedOutput ) {
+ $this->assertEquals(
+ $expectedOutput,
+ CSSMin::buildUrlValue( $input ),
+ "CSSMin::buildUrlValue: $message"
+ );
+ }
+
+ public static function provideBuildUrlValueCases() {
+ return [
+ [
+ 'Full URL',
+ 'scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s',
+ 'url(scheme://user@domain:port/~user/fi%20le.png?query=yes&really=y+s)',
+ ],
+ [
+ 'data: URI',
+ 'data:image/png;base64,R0lGODlh/+==',
+ 'url(data:image/png;base64,R0lGODlh/+==)',
+ ],
+ [
+ 'URL with quotes',
+ "https://en.wikipedia.org/wiki/Wendy's",
+ "url(\"https://en.wikipedia.org/wiki/Wendy's\")",
+ ],
+ [
+ 'URL with parentheses',
+ 'https://en.wikipedia.org/wiki/Boston_(band)',
+ 'url("https://en.wikipedia.org/wiki/Boston_(band)")',
+ ],
+ ];
+ }
+
+ /**
+ * Seperated because they are currently broken (T37492)
+ *
+ * @group Broken
+ * @dataProvider provideStringCases
+ * @covers CSSMin::remap
+ */
+ public function testMinifyWithCSSStringValues( $code, $expectedOutput ) {
+ $this->testMinifyOutput( $code, $expectedOutput );
+ }
+
+ public static function provideStringCases() {
+ return [
+ // String values should be respected
+ // - More than one space in a string value
+ [ 'foo { content: " "; }', 'foo{content:" "}' ],
+ // - Using a tab in a string value (turns into a space)
+ [ "foo { content: '\t'; }", "foo{content:'\t'}" ],
+ // - Using css-like syntax in string values
+ [
+ 'foo::after { content: "{;}"; position: absolute; }',
+ 'foo::after{content:"{;}";position:absolute}'
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php b/www/wiki/tests/phpunit/includes/libs/DeferredStringifierTest.php
new file mode 100644
index 00000000..c9cdf583
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php b/www/wiki/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php
new file mode 100644
index 00000000..1b3397c1
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/www/wiki/tests/phpunit/includes/libs/GenericArrayObjectTest.php
new file mode 100644
index 00000000..3be2b064
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/HashRingTest.php b/www/wiki/tests/phpunit/includes/libs/HashRingTest.php
new file mode 100644
index 00000000..ba288281
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/HashRingTest.php
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @group HashRing
+ */
+class HashRingTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers HashRing
+ */
+ public function testHashRing() {
+ $ring = new HashRing( [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ] );
+
+ $locations = [];
+ for ( $i = 0; $i < 20; $i++ ) {
+ $locations[ "hello$i"] = $ring->getLocation( "hello$i" );
+ }
+ $expectedLocations = [
+ "hello0" => "s5",
+ "hello1" => "s6",
+ "hello2" => "s2",
+ "hello3" => "s5",
+ "hello4" => "s6",
+ "hello5" => "s4",
+ "hello6" => "s5",
+ "hello7" => "s4",
+ "hello8" => "s5",
+ "hello9" => "s5",
+ "hello10" => "s3",
+ "hello11" => "s6",
+ "hello12" => "s1",
+ "hello13" => "s3",
+ "hello14" => "s3",
+ "hello15" => "s5",
+ "hello16" => "s4",
+ "hello17" => "s6",
+ "hello18" => "s6",
+ "hello19" => "s3"
+ ];
+
+ $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' );
+
+ $locations = [];
+ for ( $i = 0; $i < 5; $i++ ) {
+ $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 );
+ }
+
+ $expectedLocations = [
+ "hello0" => [ "s5", "s6" ],
+ "hello1" => [ "s6", "s4" ],
+ "hello2" => [ "s2", "s1" ],
+ "hello3" => [ "s5", "s6" ],
+ "hello4" => [ "s6", "s4" ],
+ ];
+ $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php b/www/wiki/tests/phpunit/includes/libs/HtmlArmorTest.php
new file mode 100644
index 00000000..c5e87e4e
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php
new file mode 100644
index 00000000..03c7b0c0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/IEUrlExtensionTest.php
@@ -0,0 +1,209 @@
+<?php
+
+/**
+ * Tests for IEUrlExtension::findIE6Extension
+ * @todo tests below for findIE6Extension should be split into...
+ * ...a dataprovider and test method.
+ */
+class IEUrlExtensionTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testSimple() {
+ $this->assertEquals(
+ 'y',
+ IEUrlExtension::findIE6Extension( 'x.y' ),
+ 'Simple extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testSimpleNoExt() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'x' ),
+ 'No extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEmpty() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '' ),
+ 'Empty string'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testQuestionMark() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '?' ),
+ 'Question mark only'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExtQuestionMark() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '.x?' ),
+ 'Extension then question mark'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testQuestionMarkExt() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '?.x' ),
+ 'Question mark then extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testInvalidChar() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '.x*' ),
+ 'Extension with invalid character'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testInvalidCharThenExtension() {
+ $this->assertEquals(
+ 'x',
+ IEUrlExtension::findIE6Extension( '*.x' ),
+ 'Invalid character followed by an extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testMultipleQuestionMarks() {
+ $this->assertEquals(
+ 'c',
+ IEUrlExtension::findIE6Extension( 'a?b?.c?.d?e?f' ),
+ 'Multiple question marks'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExeException() {
+ $this->assertEquals(
+ 'd',
+ IEUrlExtension::findIE6Extension( 'a?b?.exe?.d?.e' ),
+ '.exe exception'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testExeException2() {
+ $this->assertEquals(
+ 'exe',
+ IEUrlExtension::findIE6Extension( 'a?b?.exe' ),
+ '.exe exception 2'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testHash() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'a#b.c' ),
+ 'Hash character preceding extension'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testHash2() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'a?#b.c' ),
+ 'Hash character preceding extension 2'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testDotAtEnd() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( '.' ),
+ 'Dot at end of string'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testTwoDots() {
+ $this->assertEquals(
+ 'z',
+ IEUrlExtension::findIE6Extension( 'x.y.z' ),
+ 'Two dots'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testScriptQuery() {
+ $this->assertEquals(
+ 'php',
+ IEUrlExtension::findIE6Extension( 'example.php?foo=a&bar=b' ),
+ 'Script with query'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEscapedScriptQuery() {
+ $this->assertEquals(
+ '',
+ IEUrlExtension::findIE6Extension( 'example%2Ephp?foo=a&bar=b' ),
+ 'Script with urlencoded dot and query'
+ );
+ }
+
+ /**
+ * @covers IEUrlExtension::findIE6Extension
+ */
+ public function testEscapedScriptQueryDot() {
+ $this->assertEquals(
+ 'y',
+ IEUrlExtension::findIE6Extension( 'example%2Ephp?foo=a.x&bar=b.y' ),
+ 'Script with urlencoded dot and query with dot'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/IPTest.php b/www/wiki/tests/phpunit/includes/libs/IPTest.php
new file mode 100644
index 00000000..9702c82c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/IPTest.php
@@ -0,0 +1,672 @@
+<?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' ],
+ [ '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/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php
new file mode 100644
index 00000000..61056784
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/JavaScriptMinifierTest.php
@@ -0,0 +1,242 @@
+<?php
+
+class JavaScriptMinifierTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ 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');" ],
+
+ // newline insertion after 1000 chars: break after the "++", not before
+ [ str_repeat( ';', 996 ) . "if(x++);", str_repeat( ';', 996 ) . "if(x++\n);" ],
+
+ // 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};", false ],
+ [ "a.true = 12;", "a.true=12;", false ],
+ [ "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 provideExponentLineBreaking() {
+ return [
+ [
+ // This one gets interpreted all together by the prior code;
+ // no break at the 'E' happens.
+ '1.23456789E55',
+ ],
+ [
+ // This one breaks under the bad code; splits between 'E' and '+'
+ '1.23456789E+5',
+ ],
+ [
+ // This one breaks under the bad code; splits between 'E' and '-'
+ '1.23456789E-5',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExponentLineBreaking
+ * @covers JavaScriptMinifier::minify
+ */
+ public function testExponentLineBreaking( $num ) {
+ // Long line breaking was being incorrectly done between the base and
+ // exponent part of a number, causing a syntax error. The line should
+ // instead break at the start of the number. (T34548)
+ $prefix = 'var longVarName' . str_repeat( '_', 973 ) . '=';
+ $suffix = ',shortVarName=0;';
+
+ $input = $prefix . $num . $suffix;
+ $expected = $prefix . "\n" . $num . $suffix;
+
+ $minified = JavaScriptMinifier::minify( $input );
+
+ $this->assertEquals( $expected, $minified, "Line breaks must not occur in middle of exponent" );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php b/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php
new file mode 100644
index 00000000..695a7341
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MWMessagePackTest.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * PHP Unit tests for MWMessagePack
+ * @covers MWMessagePack
+ */
+class MWMessagePackTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * Provides test cases for MWMessagePackTest::testMessagePack
+ *
+ * Returns an array of test cases. Each case is an array of (type, value,
+ * expected encoding as hex string). The expected values were generated
+ * using <https://github.com/msgpack/msgpack-php>, which includes a
+ * serialization function.
+ */
+ public static function providePacks() {
+ $tests = [
+ [ 'nil', null, 'c0' ],
+ [ 'bool', true, 'c3' ],
+ [ 'bool', false, 'c2' ],
+ [ 'positive fixnum', 0, '00' ],
+ [ 'positive fixnum', 1, '01' ],
+ [ 'positive fixnum', 5, '05' ],
+ [ 'positive fixnum', 35, '23' ],
+ [ 'uint 8', 128, 'cc80' ],
+ [ 'uint 16', 1000, 'cd03e8' ],
+ [ 'uint 32', 100000, 'ce000186a0' ],
+ [ 'negative fixnum', -1, 'ff' ],
+ [ 'negative fixnum', -2, 'fe' ],
+ [ 'int 8', -128, 'd080' ],
+ [ 'int 8', -35, 'd0dd' ],
+ [ 'int 16', -1000, 'd1fc18' ],
+ [ 'int 32', -100000, 'd2fffe7960' ],
+ [ 'double', 0.1, 'cb3fb999999999999a' ],
+ [ 'double', 1.1, 'cb3ff199999999999a' ],
+ [ 'double', 123.456, 'cb405edd2f1a9fbe77' ],
+ [ 'fix raw', '', 'a0' ],
+ [ 'fix raw', 'foobar', 'a6666f6f626172' ],
+ [
+ 'raw 16',
+ 'Lorem ipsum dolor sit amet amet.',
+ 'da00204c6f72656d20697073756d20646f6c6f722073697420616d657420616d65742e'
+ ],
+ [
+ 'fix array',
+ [ 'abc', 'def', 'ghi' ],
+ '93a3616263a3646566a3676869'
+ ],
+ [
+ 'fix map',
+ [ 'one' => 1, 'two' => 2 ],
+ '82a36f6e6501a374776f02'
+ ],
+ ];
+
+ if ( PHP_INT_SIZE > 4 ) {
+ $tests[] = [ 'uint 64', 10000000000, 'cf00000002540be400' ];
+ $tests[] = [ 'int 64', -10000000000, 'd3fffffffdabf41c00' ];
+ $tests[] = [ 'int 64', -223372036854775807, 'd3fce66c50e2840001' ];
+ $tests[] = [ 'int 64', -9223372036854775807, 'd38000000000000001' ];
+ }
+
+ return $tests;
+ }
+
+ /**
+ * Verify that values are serialized correctly.
+ * @covers MWMessagePack::pack
+ * @dataProvider providePacks
+ */
+ public function testPack( $type, $value, $expected ) {
+ $actual = bin2hex( MWMessagePack::pack( $value ) );
+ $this->assertEquals( $expected, $actual, $type );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php b/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php
new file mode 100644
index 00000000..2a962b79
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MapCacheLRUTest.php
@@ -0,0 +1,117 @@
+<?php
+/**
+ * @group Cache
+ */
+class MapCacheLRUTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers MapCacheLRU::newFromArray()
+ * @covers MapCacheLRU::toArray()
+ * @covers MapCacheLRU::getAllKeys()
+ * @covers MapCacheLRU::clear()
+ */
+ function testArrayConversion() {
+ $raw = [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ];
+ $cache = MapCacheLRU::newFromArray( $raw, 3 );
+
+ $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()
+ );
+ }
+
+ /**
+ * @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()
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php b/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php
new file mode 100644
index 00000000..9127a30f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/MemoizedCallableTest.php
@@ -0,0 +1,142 @@
+<?php
+/**
+ * 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;
+ }
+}
+
+/**
+ * PHP Unit 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 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php
new file mode 100644
index 00000000..c8940e5f
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/ProcessCacheLRUTest.php
@@ -0,0 +1,268 @@
+<?php
+
+/**
+ * Test for ProcessCacheLRU class.
+ *
+ * Note that it uses the ProcessCacheLRUTestable class which extends some
+ * properties and methods visibility. That class is defined at the end of the
+ * file containing this class.
+ *
+ * @group Cache
+ */
+class ProcessCacheLRUTest extends 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->assertAttributeEquals( [], 'cache', $cache, $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 $cache = [];
+
+ public function getCache() {
+ return $this->cache;
+ }
+
+ public function getEntriesCount() {
+ return count( $this->cache );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php b/www/wiki/tests/phpunit/includes/libs/SamplingStatsdClientTest.php
new file mode 100644
index 00000000..7bd16115
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php b/www/wiki/tests/phpunit/includes/libs/StringUtilsTest.php
new file mode 100644
index 00000000..fcfa53e2
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/TimingTest.php b/www/wiki/tests/phpunit/includes/libs/TimingTest.php
new file mode 100644
index 00000000..581a5186
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php b/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php
new file mode 100644
index 00000000..1cbd86f1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/XhprofDataTest.php
@@ -0,0 +1,278 @@
+<?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
+ */
+
+/**
+ * @uses XhprofData
+ * @uses AutoLoader
+ * @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
+ * @uses XhprofData
+ */
+ 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
+ * @uses XhprofData
+ */
+ 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/www/wiki/tests/phpunit/includes/libs/XhprofTest.php b/www/wiki/tests/phpunit/includes/libs/XhprofTest.php
new file mode 100644
index 00000000..0ea13289
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/XhprofTest.php
@@ -0,0 +1,40 @@
+<?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 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php b/www/wiki/tests/phpunit/includes/libs/XmlTypeCheckTest.php
new file mode 100644
index 00000000..8616b419
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php
new file mode 100644
index 00000000..05ae2a37
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php
@@ -0,0 +1,499 @@
+<?php
+
+class ComposerInstalledTest extends MediaWikiTestCase {
+
+ private $installed;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->installed = "$IP/tests/phpunit/data/composer/installed.json";
+ }
+
+ /**
+ * @covers ComposerInstalled::__construct
+ * @covers ComposerInstalled::getInstalledDependencies
+ */
+ public function testGetInstalledDependencies() {
+ $installed = new ComposerInstalled( $this->installed );
+ $this->assertArrayEquals( [
+ '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(), false, true );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php
new file mode 100644
index 00000000..ded5f8fe
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerJsonTest.php
@@ -0,0 +1,42 @@
+<?php
+
+class ComposerJsonTest extends MediaWikiTestCase {
+
+ private $json, $json2;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->json = "$IP/tests/phpunit/data/composer/composer.json";
+ $this->json2 = "$IP/tests/phpunit/data/composer/new-composer.json";
+ }
+
+ /**
+ * @covers ComposerJson::__construct
+ * @covers ComposerJson::getRequiredDependencies
+ */
+ public function testGetRequiredDependencies() {
+ $json = new ComposerJson( $this->json );
+ $this->assertArrayEquals( [
+ 'cdb/cdb' => '1.0.0',
+ 'cssjanus/cssjanus' => '1.1.1',
+ 'leafo/lessphp' => '0.5.0',
+ 'psr/log' => '1.0.0',
+ ], $json->getRequiredDependencies(), false, true );
+ }
+
+ 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/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php b/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php
new file mode 100644
index 00000000..dc81e1d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/composer/ComposerLockTest.php
@@ -0,0 +1,121 @@
+<?php
+
+class ComposerLockTest extends MediaWikiTestCase {
+
+ private $lock;
+
+ public function setUp() {
+ parent::setUp();
+ global $IP;
+ $this->lock = "$IP/tests/phpunit/data/composer/composer.lock";
+ }
+
+ /**
+ * @covers ComposerLock::__construct
+ * @covers ComposerLock::getInstalledDependencies
+ */
+ public function testGetInstalledDependencies() {
+ $lock = new ComposerLock( $this->lock );
+ $this->assertArrayEquals( [
+ '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(), false, true );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php
new file mode 100644
index 00000000..02eac118
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php b/www/wiki/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php
new file mode 100644
index 00000000..e4b47b46
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php
new file mode 100644
index 00000000..fbe5a2ba
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php
@@ -0,0 +1,131 @@
+<?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 );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
new file mode 100644
index 00000000..10fba835
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
@@ -0,0 +1,300 @@
+<?php
+
+use Wikimedia\ScopedCallback;
+
+/**
+ * @author Matthias Mullie <mmullie@wikimedia.org>
+ * @group BagOStuff
+ */
+class BagOStuffTest extends MediaWikiTestCase {
+ /** @var BagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ // type defined through parameter
+ if ( $this->getCliArg( 'use-bagostuff' ) ) {
+ $name = $this->getCliArg( 'use-bagostuff' );
+
+ $this->cache = ObjectCache::newFromId( $name );
+ } else {
+ // no type defined - use simple hash
+ $this->cache = new HashBagOStuff;
+ }
+
+ $this->cache->delete( wfMemcKey( 'test' ) );
+ }
+
+ /**
+ * @covers BagOStuff::makeGlobalKey
+ * @covers BagOStuff::makeKeyInternal
+ */
+ public function testMakeKey() {
+ $cache = ObjectCache::newFromId( 'hash' );
+
+ $localKey = $cache->makeKey( 'first', 'second', 'third' );
+ $globalKey = $cache->makeGlobalKey( 'first', 'second', 'third' );
+
+ $this->assertStringMatchesFormat(
+ '%Sfirst%Ssecond%Sthird%S',
+ $localKey,
+ 'Local key interpolates parameters'
+ );
+
+ $this->assertStringMatchesFormat(
+ 'global%Sfirst%Ssecond%Sthird%S',
+ $globalKey,
+ 'Global key interpolates parameters and contains global prefix'
+ );
+
+ $this->assertNotEquals(
+ $localKey,
+ $globalKey,
+ 'Local key and global key with same parameters should not be equal'
+ );
+
+ $this->assertNotEquals(
+ $cache->makeKeyInternal( 'prefix', [ 'a', 'bc:', 'de' ] ),
+ $cache->makeKeyInternal( 'prefix', [ 'a', 'bc', ':de' ] )
+ );
+ }
+
+ /**
+ * @covers BagOStuff::merge
+ * @covers BagOStuff::mergeViaLock
+ */
+ public function testMerge() {
+ $key = wfMemcKey( 'test' );
+
+ $usleep = 0;
+
+ /**
+ * Callback method: append "merged" to whatever is in cache.
+ *
+ * @param BagOStuff $cache
+ * @param string $key
+ * @param int $existingValue
+ * @use int $usleep
+ * @return int
+ */
+ $callback = function ( BagOStuff $cache, $key, $existingValue ) use ( &$usleep ) {
+ // let's pretend this is an expensive callback to test concurrent merge attempts
+ usleep( $usleep );
+
+ if ( $existingValue === false ) {
+ return 'merged';
+ }
+
+ return $existingValue . 'merged';
+ };
+
+ // merge on non-existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( 'merged', $this->cache->get( $key ) );
+
+ // merge on existing value
+ $merged = $this->cache->merge( $key, $callback, 0 );
+ $this->assertTrue( $merged );
+ $this->assertEquals( 'mergedmerged', $this->cache->get( $key ) );
+
+ /*
+ * Test concurrent merges by forking this process, if:
+ * - not manually called with --use-bagostuff
+ * - pcntl_fork is supported by the system
+ * - cache type will correctly support calls over forks
+ */
+ $fork = (bool)$this->getCliArg( 'use-bagostuff' );
+ $fork &= function_exists( 'pcntl_fork' );
+ $fork &= !$this->cache instanceof HashBagOStuff;
+ $fork &= !$this->cache instanceof EmptyBagOStuff;
+ $fork &= !$this->cache instanceof MultiWriteBagOStuff;
+ if ( $fork ) {
+ // callback should take awhile now so that we can test concurrent merge attempts
+ $pid = pcntl_fork();
+ if ( $pid == -1 ) {
+ // can't fork, ignore this test...
+ } elseif ( $pid ) {
+ // wait a little, making sure that the child process is calling merge
+ usleep( 3000 );
+
+ // attempt a merge - this should fail
+ $merged = $this->cache->merge( $key, $callback, 0, 1 );
+
+ // merge has failed because child process was merging (and we only attempted once)
+ $this->assertFalse( $merged );
+
+ // make sure the child's merge is completed and verify
+ usleep( 3000 );
+ $this->assertEquals( $this->cache->get( $key ), 'mergedmergedmerged' );
+ } else {
+ $this->cache->merge( $key, $callback, 0, 1 );
+
+ // Note: I'm not even going to check if the merge worked, I'll
+ // compare values in the parent process to test if this merge worked.
+ // I'm just going to exit this child process, since I don't want the
+ // child to output any test results (would be rather confusing to
+ // have test output twice)
+ exit;
+ }
+ }
+ }
+
+ /**
+ * @covers BagOStuff::changeTTL
+ */
+ public function testChangeTTL() {
+ $key = wfMemcKey( 'test' );
+ $value = 'meow';
+
+ $this->cache->add( $key, $value );
+ $this->assertTrue( $this->cache->changeTTL( $key, 5 ) );
+ $this->assertEquals( $this->cache->get( $key ), $value );
+ $this->cache->delete( $key );
+ $this->assertFalse( $this->cache->changeTTL( $key, 5 ) );
+ }
+
+ /**
+ * @covers BagOStuff::add
+ */
+ public function testAdd() {
+ $key = wfMemcKey( 'test' );
+ $this->assertTrue( $this->cache->add( $key, 'test' ) );
+ }
+
+ /**
+ * @covers BagOStuff::get
+ */
+ public function testGet() {
+ $value = [ 'this' => 'is', 'a' => 'test' ];
+
+ $key = wfMemcKey( 'test' );
+ $this->cache->add( $key, $value );
+ $this->assertEquals( $this->cache->get( $key ), $value );
+ }
+
+ /**
+ * @covers BagOStuff::getWithSetCallback
+ */
+ public function testGetWithSetCallback() {
+ $key = wfMemcKey( 'test' );
+ $value = $this->cache->getWithSetCallback(
+ $key,
+ 30,
+ function () {
+ return 'hello kitty';
+ }
+ );
+
+ $this->assertEquals( 'hello kitty', $value );
+ $this->assertEquals( $value, $this->cache->get( $key ) );
+ }
+
+ /**
+ * @covers BagOStuff::incr
+ */
+ public function testIncr() {
+ $key = wfMemcKey( 'test' );
+ $this->cache->add( $key, 0 );
+ $this->cache->incr( $key );
+ $expectedValue = 1;
+ $actualValue = $this->cache->get( $key );
+ $this->assertEquals( $expectedValue, $actualValue, 'Value should be 1 after incrementing' );
+ }
+
+ /**
+ * @covers BagOStuff::incrWithInit
+ */
+ public function testIncrWithInit() {
+ $key = wfMemcKey( 'test' );
+ $val = $this->cache->incrWithInit( $key, 0, 1, 3 );
+ $this->assertEquals( 3, $val, "Correct init value" );
+
+ $val = $this->cache->incrWithInit( $key, 0, 1, 3 );
+ $this->assertEquals( 4, $val, "Correct init value" );
+ }
+
+ /**
+ * @covers BagOStuff::getMulti
+ */
+ public function testGetMulti() {
+ $value1 = [ 'this' => 'is', 'a' => 'test' ];
+ $value2 = [ 'this' => 'is', 'another' => 'test' ];
+ $value3 = [ 'testing a key that may be encoded when sent to cache backend' ];
+ $value4 = [ 'another test where chars in key will be encoded' ];
+
+ $key1 = wfMemcKey( 'test1' );
+ $key2 = wfMemcKey( 'test2' );
+ // internally, MemcachedBagOStuffs will encode to will-%25-encode
+ $key3 = wfMemcKey( 'will-%-encode' );
+ $key4 = wfMemcKey(
+ 'flowdb:flow_ref:wiki:by-source:v3:Parser\'s_"broken"_+_(page)_&_grill:testwiki:1:4.7'
+ );
+
+ $this->cache->add( $key1, $value1 );
+ $this->cache->add( $key2, $value2 );
+ $this->cache->add( $key3, $value3 );
+ $this->cache->add( $key4, $value4 );
+
+ $this->assertEquals(
+ [ $key1 => $value1, $key2 => $value2, $key3 => $value3, $key4 => $value4 ],
+ $this->cache->getMulti( [ $key1, $key2, $key3, $key4 ] )
+ );
+
+ // cleanup
+ $this->cache->delete( $key1 );
+ $this->cache->delete( $key2 );
+ $this->cache->delete( $key3 );
+ $this->cache->delete( $key4 );
+ }
+
+ /**
+ * @covers BagOStuff::getScopedLock
+ */
+ public function testGetScopedLock() {
+ $key = wfMemcKey( 'test' );
+ $value1 = $this->cache->getScopedLock( $key, 0 );
+ $value2 = $this->cache->getScopedLock( $key, 0 );
+
+ $this->assertType( ScopedCallback::class, $value1, 'First call returned lock' );
+ $this->assertNull( $value2, 'Duplicate call returned no lock' );
+
+ unset( $value1 );
+
+ $value3 = $this->cache->getScopedLock( $key, 0 );
+ $this->assertType( ScopedCallback::class, $value3, 'Lock returned callback after release' );
+ unset( $value3 );
+
+ $value1 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );
+ $value2 = $this->cache->getScopedLock( $key, 0, 5, 'reentry' );
+
+ $this->assertType( ScopedCallback::class, $value1, 'First reentrant call returned lock' );
+ $this->assertType( ScopedCallback::class, $value1, 'Second reentrant call returned lock' );
+ }
+
+ /**
+ * @covers BagOStuff::__construct
+ * @covers BagOStuff::trackDuplicateKeys
+ */
+ public function testReportDupes() {
+ $logger = $this->createMock( Psr\Log\NullLogger::class );
+ $logger->expects( $this->once() )
+ ->method( 'warning' )
+ ->with( 'Duplicate get(): "{key}" fetched {count} times', [
+ 'key' => 'foo',
+ 'count' => 2,
+ ] );
+
+ $cache = new HashBagOStuff( [
+ 'reportDupes' => true,
+ 'asyncHandler' => 'DeferredUpdates::addCallableUpdate',
+ 'logger' => $logger,
+ ] );
+ $cache->get( 'foo' );
+ $cache->get( 'bar' );
+ $cache->get( 'foo' );
+
+ DeferredUpdates::doUpdates();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
new file mode 100644
index 00000000..d0360a99
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
@@ -0,0 +1,158 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group BagOStuff
+ */
+class CachedBagOStuffTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @covers CachedBagOStuff::__construct
+ * @covers CachedBagOStuff::doGet
+ */
+ 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::doGet
+ */
+ 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/www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php
new file mode 100644
index 00000000..332e23b2
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php
new file mode 100644
index 00000000..4a9f6cc9
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/MultiWriteBagOStuffTest.php
@@ -0,0 +1,140 @@
+<?php
+
+/**
+ * @group Database
+ */
+class MultiWriteBagOStuffTest extends MediaWikiTestCase {
+ /** @var HashBagOStuff */
+ private $cache1;
+ /** @var HashBagOStuff */
+ private $cache2;
+ /** @var MultiWriteBagOStuff */
+ private $cache;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->cache1 = new HashBagOStuff();
+ $this->cache2 = new HashBagOStuff();
+ $this->cache = new MultiWriteBagOStuff( [
+ 'caches' => [ $this->cache1, $this->cache2 ],
+ 'replication' => 'async',
+ 'asyncHandler' => 'DeferredUpdates::addCallableUpdate'
+ ] );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::set
+ * @covers MultiWriteBagOStuff::doWrite
+ */
+ public function testSetImmediate() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff
+ */
+ public function testSyncMerge() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $func = function () use ( $value ) {
+ return $value;
+ };
+
+ // XXX: DeferredUpdates bound to transactions in CLI mode
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->begin();
+ $this->cache->merge( $key, $func );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Not yet set in tier 2
+ $this->assertEquals( false, $this->cache2->get( $key ), 'Not written to tier 2' );
+
+ $dbw->commit();
+
+ // Set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+
+ $key = wfRandomString();
+
+ $dbw->begin();
+ $this->cache->merge( $key, $func, 0, 1, BagOStuff::WRITE_SYNC );
+
+ // Set in tier 1
+ $this->assertEquals( $value, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Also set in tier 2
+ $this->assertEquals( $value, $this->cache2->get( $key ), 'Written to tier 2' );
+
+ $dbw->commit();
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::set
+ */
+ public function testSetDelayed() {
+ $key = wfRandomString();
+ $value = (object)[ 'v' => wfRandomString() ];
+ $expectValue = clone $value;
+
+ // XXX: DeferredUpdates bound to transactions in CLI mode
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->begin();
+ $this->cache->set( $key, $value );
+
+ // Test that later changes to $value don't affect the saved value (e.g. T168040)
+ $value->v = 'bogus';
+
+ // Set in tier 1
+ $this->assertEquals( $expectValue, $this->cache1->get( $key ), 'Written to tier 1' );
+ // Not yet set in tier 2
+ $this->assertEquals( false, $this->cache2->get( $key ), 'Not written to tier 2' );
+
+ $dbw->commit();
+
+ // Set in tier 2
+ $this->assertEquals( $expectValue, $this->cache2->get( $key ), 'Written to tier 2' );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::makeKey
+ */
+ public function testMakeKey() {
+ $cache1 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )->getMock();
+ $cache1->expects( $this->once() )->method( 'makeKey' )
+ ->willReturn( 'special' );
+
+ $cache2 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeKey' ] )->getMock();
+ $cache2->expects( $this->never() )->method( 'makeKey' );
+
+ $cache = new MultiWriteBagOStuff( [ 'caches' => [ $cache1, $cache2 ] ] );
+ $this->assertSame( 'special', $cache->makeKey( 'a', 'b' ) );
+ }
+
+ /**
+ * @covers MultiWriteBagOStuff::makeGlobalKey
+ */
+ public function testMakeGlobalKey() {
+ $cache1 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )->getMock();
+ $cache1->expects( $this->once() )->method( 'makeGlobalKey' )
+ ->willReturn( 'special' );
+
+ $cache2 = $this->getMockBuilder( HashBagOStuff::class )
+ ->setMethods( [ 'makeGlobalKey' ] )->getMock();
+ $cache2->expects( $this->never() )->method( 'makeGlobalKey' );
+
+ $cache = new MultiWriteBagOStuff( [ 'caches' => [ $cache1, $cache2 ] ] );
+
+ $this->assertSame( 'special', $cache->makeGlobalKey( 'a', 'b' ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php
new file mode 100644
index 00000000..1b502ec1
--- /dev/null
+++ b/www/wiki/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 = wfRandomString();
+ $value = wfRandomString();
+ $this->cache->set( $key, $value );
+
+ // Write to master.
+ $this->assertEquals( $this->writeCache->get( $key ), $value );
+ // Don't write to slave. Replication is deferred to backend.
+ $this->assertEquals( $this->readCache->get( $key ), false );
+ }
+
+ /**
+ * @covers ReplicatedBagOStuff::get
+ */
+ public function testGet() {
+ $key = wfRandomString();
+
+ $write = wfRandomString();
+ $this->writeCache->set( $key, $write );
+ $read = wfRandomString();
+ $this->readCache->set( $key, $read );
+
+ // Read from slave.
+ $this->assertEquals( $this->cache->get( $key ), $read );
+ }
+
+ /**
+ * @covers ReplicatedBagOStuff::get
+ */
+ public function testGetAbsent() {
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $this->writeCache->set( $key, $value );
+
+ // Don't read from master. No failover if value is absent.
+ $this->assertEquals( $this->cache->get( $key ), false );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
new file mode 100644
index 00000000..662bb961
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php
@@ -0,0 +1,1711 @@
+<?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(),
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $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() {
+ $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 ) {
+ $this->cache->delete( $key );
+ $this->cache->getWithSetCallback(
+ $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] );
+ }
+ $this->assertEquals( 9, $hit, "Values evicted" );
+
+ $key = reset( $keys );
+ // Get into cache (default process cache group)
+ $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] );
+ $this->assertEquals( 10, $hit, "Value calculated" );
+ $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] );
+ $this->assertEquals( 10, $hit, "Value cached" );
+ $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;
+ };
+
+ $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 = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $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.01;
+ $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 = microtime( true );
+ $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)" );
+
+ // Change 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" );
+ }
+
+ 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(),
+ 'pool' => 'empty',
+ ] );
+
+ $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" );
+ $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(),
+ 'pool' => 'empty',
+ 'asyncHandler' => $asyncHandler
+ ] );
+
+ $mockWallClock = microtime( true );
+ $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(),
+ 'pool' => 'empty'
+ ] );
+
+ $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$";
+ };
+
+ $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 = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $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, 'pool' => 'testcache-hash' ] );
+
+ // 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;
+ };
+
+ $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 = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $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();
+
+ $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 );
+ $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();
+ $value = wfRandomString();
+
+ $calls = 0;
+ $func = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, $cache, $key ) {
+ ++$calls;
+ $setOpts['since'] = microtime( true ) - 10;
+ // Immediately kill any mutex rather than waiting a second
+ $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+ return $value;
+ };
+
+ // Value should be marked as stale due to snapshot lag
+ $curTTL = null;
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( $value, $cache->get( $key, $curTTL ), 'Value was populated' );
+ $this->assertLessThan( 0, $curTTL, 'Value has negative curTTL' );
+ $this->assertEquals( 1, $calls, 'Value was generated' );
+
+ // Acquire a lock to verify that getWithSetCallback uses lockTSE properly
+ $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 );
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( 1, $calls, 'Callback was not used' );
+ }
+
+ /**
+ * @covers WANObjectCache::getWithSetCallback()
+ * @covers WANObjectCache::doGetWithSetCallback()
+ */
+ public function testBusyValue() {
+ $cache = $this->cache;
+ $key = wfRandomString();
+ $value = wfRandomString();
+ $busyValue = wfRandomString();
+
+ $calls = 0;
+ $func = function () use ( &$calls, $value, $cache, $key ) {
+ ++$calls;
+ // Immediately kill any mutex rather than waiting a second
+ $cache->delete( $cache::MUTEX_KEY_PREFIX . $key );
+ return $value;
+ };
+
+ $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue ] );
+ $this->assertEquals( $value, $ret );
+ $this->assertEquals( 1, $calls, 'Value was populated' );
+
+ // 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 );
+ $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();
+
+ $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 = microtime( true );
+ $priorTime = $mockWallClock; // reference time
+ $cache->setMockTime( $mockWallClock );
+
+ $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 = microtime( true );
+ $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;
+
+ $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 );
+ $v = $cache->getWithSetCallback( $key, 60, $func );
+ $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim
+ $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 = microtime( true );
+ $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';
+
+ // 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 . ( microtime( true ) - 3 )
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey2,
+ WANObjectCache::PURGE_VAL_PREFIX . ( microtime( true ) - 5 )
+ );
+ $this->internalCache->set(
+ WANObjectCache::TIME_KEY_PREFIX . $tKey1,
+ WANObjectCache::PURGE_VAL_PREFIX . ( microtime( true ) - 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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ '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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ '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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ '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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] ),
+ 'mcrouterAware' => true,
+ 'region' => 'pmtpa',
+ 'cluster' => 'mw-wan'
+ ] );
+
+ $localBag->expects( $this->once() )->method( 'delete' )
+ ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" );
+
+ $wanCache->resetCheckKey( 'test' );
+ }
+
+ /**
+ * @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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $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,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] );
+
+ $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::determineKeyClass
+ */
+ public function testStatsKeyClass( $key, $class ) {
+ $wanCache = TestingAccessWrapper::newFromObject( new WANObjectCache( [
+ 'cache' => new HashBagOStuff,
+ 'pool' => 'testcache-hash',
+ 'relayer' => new EventRelayerNull( [] )
+ ] ) );
+
+ $this->assertEquals( $class, $wanCache->determineKeyClass( $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/www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php
new file mode 100644
index 00000000..538d625c
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php
new file mode 100644
index 00000000..dd86a73e
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php
new file mode 100644
index 00000000..8d7d104c
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
new file mode 100644
index 00000000..c3cddc61
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
@@ -0,0 +1,148 @@
+<?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 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();
+
+ $db->method( 'select' )->willReturn( new FakeResultWrapper( [] ) );
+ $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() );
+ }
+
+ public function testConstruct() {
+ $lb = $this->getLoadBalancerMock();
+ $ref = new DBConnRef( $lb, $this->getDatabaseMock() );
+
+ $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 ]
+ );
+
+ $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+ }
+
+ 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 ); // bad constructor argument
+ }
+
+ public function testGetWikiID() {
+ $lb = $this->getMock( ILoadBalancer::class );
+
+ // getWikiID is optimized to not create a connection
+ $lb->expects( $this->never() )
+ ->method( 'getConnection' );
+
+ $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ] );
+
+ $this->assertSame( 'dummy', $ref->getWikiID() );
+ }
+
+ 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 ] );
+
+ $this->assertSame( 'dummy', $ref->getDomainID() );
+ }
+
+ 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 ] );
+ $this->assertInternalType( 'string', $ref->__toString() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php
new file mode 100644
index 00000000..b2e71554
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php
@@ -0,0 +1,133 @@
+<?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() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php
new file mode 100644
index 00000000..b28a5b9e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php
@@ -0,0 +1,55 @@
+<?php
+
+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 );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php
new file mode 100644
index 00000000..93192d01
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php
@@ -0,0 +1,743 @@
+<?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
+ [
+ self::createUnicodeString( '`\u0001a\uFFFFb`' ),
+ self::createUnicodeString( '\u0001a\uFFFFb' )
+ ],
+ [
+ self::createUnicodeString( '`\u0001\uFFFF`' ),
+ self::createUnicodeString( '\u0001\u0000\uFFFF\u0000' )
+ ],
+ [ '`☃`', '☃' ],
+ [ '`メインページ`', 'メインページ' ],
+ [ '`Басты_бет`', 'Басты_бет' ],
+
+ // Real world:
+ [ '`Alix`', 'Alix' ], # while( ! $recovered ) { sleep(); }
+ [ '`Backtick: ```', 'Backtick: `' ],
+ [ '`This is a test`', 'This is a test' ],
+ ];
+ }
+
+ private static function createUnicodeString( $str ) {
+ return json_decode( '"' . $str . '"' );
+ }
+
+ private function getMockForViews() {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'fetchRow', 'query' ] )
+ ->getMock();
+
+ $db->method( 'query' )
+ ->with( $this->anything() )
+ ->willReturn( new FakeResultWrapper( [
+ (object)[ 'Tables_in_' => 'view1' ],
+ (object)[ 'Tables_in_' => 'view2' ],
+ (object)[ 'Tables_in_' => 'myview' ]
+ ] ) );
+
+ 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' ] )
+ ->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' ] )
+ ->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/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
new file mode 100644
index 00000000..ab2f11b5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
@@ -0,0 +1,2067 @@
+<?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
+ */
+ public function testSelect( $sql, $sqlText ) {
+ $this->database->select(
+ $sql['tables'],
+ $sql['fields'],
+ isset( $sql['conds'] ) ? $sql['conds'] : [],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : [],
+ isset( $sql['join_conds'] ) ? $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', 'LOCK IN SHARE MODE' ],
+ ],
+ "SELECT DISTINCT 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"
+ ],
+ ];
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Subquery
+ * @dataProvider provideSelectRowCount
+ * @param $sql
+ * @param $sqlText
+ */
+ public function testSelectRowCount( $sql, $sqlText ) {
+ $this->database->selectRowCount(
+ $sql['tables'],
+ $sql['field'],
+ isset( $sql['conds'] ) ? $sql['conds'] : [],
+ __METHOD__,
+ isset( $sql['options'] ) ? $sql['options'] : [],
+ isset( $sql['join_conds'] ) ? $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__,
+ isset( $sql['options'] ) ? $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__,
+ isset( $sql['options'] ) ? $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__,
+ isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [],
+ isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [],
+ isset( $sql['selectJoinConds'] ) ? $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__,
+ isset( $sql['insertOptions'] ) ? $sql['insertOptions'] : [],
+ isset( $sql['selectOptions'] ) ? $sql['selectOptions'] : [],
+ isset( $sql['selectJoinConds'] ) ? $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 WHERE *",
+ "SELECT field_select AS field_insert,field2 AS field " .
+ "FROM select_table WHERE * 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')"
+ ],
+ ];
+ }
+
+ 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 WHERE * 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'],
+ isset( $params['extra_conds'] ) ? $params['extra_conds'] : '',
+ 'FNAME',
+ isset( $params['options'] ) ? $params['options'] : [],
+ isset( $params['join_conds'] ) ? $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::registerTempTableOperation
+ */
+ 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'
+ ];
+ $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( $callback1, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionIdle( $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( $callback1, __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->onTransactionPreCommitOrIdle( $callback3, __METHOD__ );
+ $this->database->endAtomic( __METHOD__ . '_outer' );
+ $this->assertLastSql( implode( "; ", [
+ 'BEGIN',
+ 'SAVEPOINT wikimedia_rdbms_atomic1',
+ 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+ 'SELECT 1, - AS t',
+ 'SELECT 3, - AS t',
+ 'COMMIT'
+ ] ) );
+
+ $this->database->startAtomic( __METHOD__ . '_outer' );
+ $this->database->onTransactionIdle( $callback1, __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->onTransactionIdle( $callback2, __METHOD__ );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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
+ */
+ 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 gets silently rolled back
+ $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL );
+ call_user_func( $doError );
+ $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ );
+ $this->database->commit( __METHOD__, Database::FLUSHING_INTERNAL );
+ // phpcs:ignore
+ $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' );
+
+ // ... unless 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->onTransactionIdle( function () use ( $fname ) {
+ $this->database->query( 'SELECT 1', $fname );
+ } );
+ $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
+ $this->database->close();
+
+ $this->assertFalse( $this->database->isOpen() );
+ $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT; SELECT 1' );
+ $this->assertEquals( 0, $this->database->trxLevel() );
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::close
+ */
+ public function testPrematureClose2() {
+ try {
+ $fname = __METHOD__;
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->onTransactionIdle( 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; COMMIT' );
+ $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/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php
new file mode 100644
index 00000000..a886d6bf
--- /dev/null
+++ b/www/wiki/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/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php
new file mode 100644
index 00000000..444a946e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php
@@ -0,0 +1,613 @@
+<?php
+
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\IDatabase;
+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;
+
+class DatabaseTest extends PHPUnit\Framework\TestCase {
+
+ 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::onTransactionIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
+ */
+ public function testTransactionIdle() {
+ $db = $this->db;
+
+ $db->clearFlag( DBO_TRX );
+ $called = false;
+ $flagSet = null;
+ $callback = function () use ( $db, &$flagSet, &$called ) {
+ $called = true;
+ $flagSet = $db->getFlag( DBO_TRX );
+ };
+
+ $db->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle(
+ function () use ( $db ) {
+ $db->setFlag( DBO_TRX );
+ },
+ __METHOD__
+ );
+ $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
+ }
+
+ /**
+ * @covers Wikimedia\Rdbms\Database::onTransactionIdle
+ * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks
+ */
+ public function testTransactionIdle_TRX() {
+ $db = $this->getMockDB( [ 'isOpen', 'ping' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+ $db->method( 'ping' )->willReturn( true );
+ $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->onTransactionIdle( $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->onTransactionIdle( $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->onTransactionIdle( $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::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 () use ( &$called ) {
+ $called = true;
+ },
+ __METHOD__
+ );
+ $this->assertTrue( $called, 'Called when idle' );
+
+ $db->begin( __METHOD__ );
+ $called = false;
+ $db->onTransactionPreCommitOrIdle(
+ function () 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' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+ $db->method( 'ping' )->willReturn( true );
+ $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 () 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 () use ( $db, &$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 () use ( $db, &$called ) {
+ $called = true;
+ $db->setFlag( DBO_TRX );
+ } );
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $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',
+ ];
+ $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();
+ 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' ] );
+ $db->method( 'isOpen' )->willReturn( true );
+
+ $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 );
+
+ $this->assertEquals( 0, $db->trxLevel() );
+
+ $db->setFlag( DBO_TRX );
+ try {
+ $this->badLockingMethodImplicit( $db );
+ } catch ( RunTimeException $e ) {
+ $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+ }
+ $db->clearFlag( DBO_TRX );
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+
+ try {
+ $this->badLockingMethodExplicit( $db );
+ } catch ( RunTimeException $e ) {
+ $this->assertTrue( $db->trxLevel() > 0, "Transaction not committed." );
+ }
+ $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
+ $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
+ }
+
+ private function badLockingMethodImplicit( IDatabase $db ) {
+ $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+ $db->query( "SELECT 1" ); // trigger DBO_TRX
+ throw new RunTimeException( "Uh oh!" );
+ }
+
+ private function badLockingMethodExplicit( IDatabase $db ) {
+ $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
+ $db->begin( __METHOD__ );
+ throw new RunTimeException( "Uh oh!" );
+ }
+
+ /**
+ * @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 testMutators() {
+ $old = $this->db->tablePrefix();
+ $this->assertInternalType( 'string', $old, 'Prefix is string' );
+ $this->assertEquals( $old, $this->db->tablePrefix(), "Prefix unchanged" );
+ $this->assertEquals( $old, $this->db->tablePrefix( 'xxx' ) );
+ $this->assertEquals( 'xxx', $this->db->tablePrefix(), "Prefix set" );
+ $this->db->tablePrefix( $old );
+ $this->assertNotEquals( 'xxx', $this->db->tablePrefix() );
+
+ $old = $this->db->dbSchema();
+ $this->assertInternalType( 'string', $old, 'Schema is string' );
+ $this->assertEquals( $old, $this->db->dbSchema(), "Schema unchanged" );
+ $this->assertEquals( $old, $this->db->dbSchema( 'xxx' ) );
+ $this->assertEquals( 'xxx', $this->db->dbSchema(), "Schema set" );
+ $this->db->dbSchema( $old );
+ $this->assertNotEquals( 'xxx', $this->db->dbSchema() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php b/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php
new file mode 100644
index 00000000..73fd4716
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/xmp/XMPTest.php
@@ -0,0 +1,227 @@
+<?php
+
+/**
+ * @group Media
+ * @covers XMPReader
+ */
+class XMPTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ parent::setUp();
+ # Requires libxml to do XMP parsing
+ if ( !extension_loaded( 'exif' ) ) {
+ $this->markTestSkipped( "PHP extension 'exif' is not loaded, skipping." );
+ }
+ }
+
+ /**
+ * Put XMP in, compare what comes out...
+ *
+ * @param string $xmp The actual xml data.
+ * @param array $expected Expected result of parsing the xmp.
+ * @param string $info Short sentence on what's being tested.
+ *
+ * @throws Exception
+ * @dataProvider provideXMPParse
+ *
+ * @covers XMPReader::parse
+ */
+ public function testXMPParse( $xmp, $expected, $info ) {
+ if ( !is_string( $xmp ) || !is_array( $expected ) ) {
+ throw new Exception( "Invalid data provided to " . __METHOD__ );
+ }
+ $reader = new XMPReader;
+ $reader->parse( $xmp );
+ $this->assertEquals( $expected, $reader->getResults(), $info, 0.0000000001 );
+ }
+
+ public static function provideXMPParse() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $data = [];
+
+ // $xmpFiles format: array of arrays with first arg file base name,
+ // with the actual file having .xmp on the end for the xmp
+ // and .result.php on the end for a php file containing the result
+ // array. Second argument is some info on what's being tested.
+ $xmpFiles = [
+ [ '1', 'parseType=Resource test' ],
+ [ '2', 'Structure with mixed attribute and element props' ],
+ [ '3', 'Extra qualifiers (that should be ignored)' ],
+ [ '3-invalid', 'Test ignoring qualifiers that look like normal props' ],
+ [ '4', 'Flash as qualifier' ],
+ [ '5', 'Flash as qualifier 2' ],
+ [ '6', 'Multiple rdf:Description' ],
+ [ '7', 'Generic test of several property types' ],
+ [ 'flash', 'Test of Flash property' ],
+ [ 'invalid-child-not-struct', 'Test child props not in struct or ignored' ],
+ [ 'no-recognized-props', 'Test namespace and no recognized props' ],
+ [ 'no-namespace', 'Test non-namespaced attributes are ignored' ],
+ [ 'bag-for-seq', "Allow bag's instead of seq's. (T29105)" ],
+ [ 'utf16BE', 'UTF-16BE encoding' ],
+ [ 'utf16LE', 'UTF-16LE encoding' ],
+ [ 'utf32BE', 'UTF-32BE encoding' ],
+ [ 'utf32LE', 'UTF-32LE encoding' ],
+ [ 'xmpExt', 'Extended XMP missing second part' ],
+ [ 'gps', 'Handling of exif GPS parameters in XMP' ],
+ ];
+
+ $xmpFiles[] = [ 'doctype-included', 'XMP includes doctype' ];
+
+ foreach ( $xmpFiles as $file ) {
+ $xmp = file_get_contents( $xmpPath . $file[0] . '.xmp' );
+ // I'm not sure if this is the best way to handle getting the
+ // result array, but it seems kind of big to put directly in the test
+ // file.
+ $result = null;
+ include $xmpPath . $file[0] . '.result.php';
+ $data[] = [ $xmp, $result, '[' . $file[0] . '.xmp] ' . $file[1] ];
+ }
+
+ return $data;
+ }
+
+ /** Test ExtendedXMP block support. (Used when the XMP has to be split
+ * over multiple jpeg segments, due to 64k size limit on jpeg segments.
+ *
+ * @todo This is based on what the standard says. Need to find a real
+ * world example file to double check the support for this is right.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMP() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 0 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ 'FNumber' => '2/10',
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * This test has an extended XMP block with a wrong guid (md5sum)
+ * and thus should only return the StandardXMP, not the ExtendedXMP.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMPWithWrongGUID() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B9'; // Note last digit.
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 0 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * Have a high offset to simulate a missing packet,
+ * which should cause it to ignore the ExtendedXMP packet.
+ *
+ * @covers XMPReader::parseExtended
+ */
+ public function testExtendedXMPMissingPacket() {
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $standardXMP = file_get_contents( $xmpPath . 'xmpExt.xmp' );
+ $extendedXMP = file_get_contents( $xmpPath . 'xmpExt2.xmp' );
+
+ $md5sum = '28C74E0AC2D796886759006FBE2E57B7'; // of xmpExt2.xmp
+ $length = pack( 'N', strlen( $extendedXMP ) );
+ $offset = pack( 'N', 2048 );
+ $extendedPacket = $md5sum . $length . $offset . $extendedXMP;
+
+ $reader = new XMPReader();
+ $reader->parse( $standardXMP );
+ $reader->parseExtended( $extendedPacket );
+ $actual = $reader->getResults();
+
+ $expected = [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => 9,
+ ]
+ ];
+
+ $this->assertEquals( $expected, $actual );
+ }
+
+ /**
+ * Test for multi-section, hostile XML
+ * @covers XMPReader::checkParseSafety
+ */
+ public function testCheckParseSafety() {
+ // Test for detection
+ $xmpPath = __DIR__ . '/../../../data/xmp/';
+ $file = fopen( $xmpPath . 'doctype-included.xmp', 'rb' );
+ $valid = false;
+ $reader = new XMPReader();
+ do {
+ $chunk = fread( $file, 10 );
+ $valid = $reader->parse( $chunk, feof( $file ) );
+ } while ( !feof( $file ) );
+ $this->assertFalse( $valid, 'Check that doctype is detected in fragmented XML' );
+ $this->assertEquals(
+ [],
+ $reader->getResults(),
+ 'Check that doctype is detected in fragmented XML'
+ );
+ fclose( $file );
+ unset( $reader );
+
+ // Test for false positives
+ $file = fopen( $xmpPath . 'doctype-not-included.xmp', 'rb' );
+ $valid = false;
+ $reader = new XMPReader();
+ do {
+ $chunk = fread( $file, 10 );
+ $valid = $reader->parse( $chunk, feof( $file ) );
+ } while ( !feof( $file ) );
+ $this->assertTrue(
+ $valid,
+ 'Check for false-positive detecting doctype in fragmented XML'
+ );
+ $this->assertEquals(
+ [
+ 'xmp-exif' => [
+ 'DigitalZoomRatio' => '0/10',
+ 'Flash' => '9'
+ ]
+ ],
+ $reader->getResults(),
+ 'Check that doctype is detected in fragmented XML'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php b/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php
new file mode 100644
index 00000000..746f68ac
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/libs/xmp/XMPValidateTest.php
@@ -0,0 +1,55 @@
+<?php
+
+use Psr\Log\NullLogger;
+
+/**
+ * @group Media
+ */
+class XMPValidateTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ /**
+ * @dataProvider provideDates
+ * @covers XMPValidate::validateDate
+ */
+ public function testValidateDate( $value, $expected ) {
+ // The method should modify $value.
+ $validate = new XMPValidate( new NullLogger() );
+ $validate->validateDate( [], $value, true );
+ $this->assertEquals( $expected, $value );
+ }
+
+ public static function provideDates() {
+ /* For reference valid date formats are:
+ * YYYY
+ * YYYY-MM
+ * YYYY-MM-DD
+ * YYYY-MM-DDThh:mmTZD
+ * YYYY-MM-DDThh:mm:ssTZD
+ * YYYY-MM-DDThh:mm:ss.sTZD
+ * (Time zone is optional)
+ */
+ return [
+ [ '1992', '1992' ],
+ [ '1992-04', '1992:04' ],
+ [ '1992-02-01', '1992:02:01' ],
+ [ '2011-09-29', '2011:09:29' ],
+ [ '1982-12-15T20:12', '1982:12:15 20:12' ],
+ [ '1982-12-15T20:12Z', '1982:12:15 20:12' ],
+ [ '1982-12-15T20:12+02:30', '1982:12:15 22:42' ],
+ [ '1982-12-15T01:12-02:30', '1982:12:14 22:42' ],
+ [ '1982-12-15T20:12:11', '1982:12:15 20:12:11' ],
+ [ '1982-12-15T20:12:11Z', '1982:12:15 20:12:11' ],
+ [ '1982-12-15T20:12:11+01:10', '1982:12:15 21:22:11' ],
+ [ '2045-12-15T20:12:11', '2045:12:15 20:12:11' ],
+ [ '1867-06-01T15:00:00', '1867:06:01 15:00:00' ],
+ /* some invalid ones */
+ [ '2001--12', null ],
+ [ '2001-5-12', null ],
+ [ '2001-5-12TZ', null ],
+ [ '2001-05-12T15', null ],
+ [ '2001-12T15:13', null ],
+ ];
+ }
+}