From: Legoktm Date: Thu, 13 Jun 2019 23:00:08 +0000 (+0000) Subject: Revert "Separate MediaWiki unit and integration tests" X-Git-Tag: 1.34.0-rc.0~1418^2 X-Git-Url: http://git.cyclocoop.org/data/%24oldEdit?a=commitdiff_plain;h=4e35134f7a3228a8195a07f49c85188e57ab8487;p=lhc%2Fweb%2Fwiklou.git Revert "Separate MediaWiki unit and integration tests" This reverts commit 0a2b996278e57a8b8c5377cd3a3eaa54f993d4a9. Reason for revert: Broke postgres tests. Change-Id: I27d8e0c807ad5f0748b9611a4f3df84cc213fbe1 --- diff --git a/.phpcs.xml b/.phpcs.xml index 1d5ce0b12c..9ccf5657b7 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -179,7 +179,6 @@ */maintenance/storage/trackBlobs\.php */tests/phpunit/includes/GlobalFunctions/*\.php - */tests/phpunit/unit/includes/GlobalFunctions/*\.php */tests/phpunit/maintenance/*\.php diff --git a/tests/common/TestSetup.php b/tests/common/TestSetup.php index 6d250be096..e24c4c5442 100644 --- a/tests/common/TestSetup.php +++ b/tests/common/TestSetup.php @@ -18,9 +18,6 @@ class TestSetup { global $wgSessionProviders, $wgSessionPbkdf2Iterations; global $wgJobTypeConf; global $wgAuthManagerConfig; - global $wgSecretKey; - - $wgSecretKey = 'secretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecretsecret'; // wfWarn should cause tests to fail $wgDevelopmentWarnings = true; diff --git a/tests/common/TestsAutoLoader.php b/tests/common/TestsAutoLoader.php index 3eb8c9a721..861111a775 100644 --- a/tests/common/TestsAutoLoader.php +++ b/tests/common/TestsAutoLoader.php @@ -60,7 +60,6 @@ $wgAutoloadClasses += [ 'MediaWikiPHPUnitResultPrinter' => "$testDir/phpunit/MediaWikiPHPUnitResultPrinter.php", 'MediaWikiPHPUnitTestListener' => "$testDir/phpunit/MediaWikiPHPUnitTestListener.php", 'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiTestCase.php", - 'MediaWikiUnitTestCase' => "$testDir/phpunit/MediaWikiUnitTestCase.php", 'MediaWikiTestResult' => "$testDir/phpunit/MediaWikiTestResult.php", 'MediaWikiTestRunner' => "$testDir/phpunit/MediaWikiTestRunner.php", 'PHPUnit4And6Compat' => "$testDir/phpunit/PHPUnit4And6Compat.php", @@ -178,7 +177,7 @@ $wgAutoloadClasses += [ 'LanguageClassesTestCase' => "$testDir/phpunit/languages/LanguageClassesTestCase.php", # tests/phpunit/includes/libs - 'GenericArrayObjectTest' => "$testDir/phpunit/unit/includes/libs/GenericArrayObjectTest.php", + 'GenericArrayObjectTest' => "$testDir/phpunit/includes/libs/GenericArrayObjectTest.php", # tests/phpunit/maintenance 'MediaWiki\Tests\Maintenance\DumpAsserter' => "$testDir/phpunit/maintenance/DumpAsserter.php", diff --git a/tests/phpunit/MediaWikiUnitTestCase.php b/tests/phpunit/MediaWikiUnitTestCase.php deleted file mode 100644 index 9ecc043187..0000000000 --- a/tests/phpunit/MediaWikiUnitTestCase.php +++ /dev/null @@ -1,36 +0,0 @@ - $factory ) { - $services->disableService( $serviceName ); - $services->redefineService( $serviceName, $factory ); - } - - $this->mwServicesBackup = MediaWikiServices::forceGlobalInstance( $services ); - } - - protected function tearDown() { - parent::tearDown(); - - if ( $this->mwServicesBackup ) { - MediaWikiServices::forceGlobalInstance( $this->mwServicesBackup ); - } - } -} diff --git a/tests/phpunit/documentation/ReleaseNotesTest.php b/tests/phpunit/documentation/ReleaseNotesTest.php new file mode 100644 index 0000000000..d20fcff7b8 --- /dev/null +++ b/tests/phpunit/documentation/ReleaseNotesTest.php @@ -0,0 +1,69 @@ +assertGreaterThanOrEqual( + 1, + count( $notesFiles ), + 'Repo has at least one Release Notes file.' + ); + + $versionParts = explode( '.', explode( '-', $wgVersion )[0] ); + $this->assertContains( + "$IP/RELEASE-NOTES-$versionParts[0].$versionParts[1]", + $notesFiles, + 'Repo has a Release Notes file for the current $wgVersion.' + ); + + foreach ( $notesFiles as $index => $fileName ) { + $this->assertFileLength( "Release Notes", $fileName ); + } + + // Also test the README and similar files + $otherFiles = [ + "$IP/COPYING", + "$IP/FAQ", + "$IP/HISTORY", + "$IP/INSTALL", + "$IP/README", + "$IP/SECURITY" + ]; + + foreach ( $otherFiles as $index => $fileName ) { + $this->assertFileLength( "Help", $fileName ); + } + } + + private function assertFileLength( $type, $fileName ) { + $file = file( $fileName, FILE_IGNORE_NEW_LINES ); + + $this->assertFalse( + !$file, + "$type file '$fileName' is inaccessible." + ); + + foreach ( $file as $i => $line ) { + $num = $i + 1; + $this->assertLessThanOrEqual( + // FILE_IGNORE_NEW_LINES drops the \n at the EOL, so max length is 80 not 81. + 80, + mb_strlen( $line ), + "$type file '$fileName' line $num, is longer than 80 chars:\n\t'$line'" + ); + } + } +} diff --git a/tests/phpunit/includes/CommentStoreCommentTest.php b/tests/phpunit/includes/CommentStoreCommentTest.php new file mode 100644 index 0000000000..2dfe03ad6b --- /dev/null +++ b/tests/phpunit/includes/CommentStoreCommentTest.php @@ -0,0 +1,26 @@ +assertSame( $message, $comment->message ); + } + + public function testConstructorWithoutMessage() { + $text = '{{template|param}}'; + $comment = new CommentStoreComment( null, $text ); + + $this->assertSame( $text, $comment->message->text() ); + } + +} diff --git a/tests/phpunit/includes/DerivativeRequestTest.php b/tests/phpunit/includes/DerivativeRequestTest.php new file mode 100644 index 0000000000..f33022b352 --- /dev/null +++ b/tests/phpunit/includes/DerivativeRequestTest.php @@ -0,0 +1,21 @@ +setIP( '1.2.3.4' ); + $derivative = new DerivativeRequest( $original, [] ); + + $this->assertEquals( '1.2.3.4', $derivative->getIP() ); + + $derivative->setIP( '5.6.7.8' ); + + $this->assertEquals( '5.6.7.8', $derivative->getIP() ); + $this->assertEquals( '1.2.3.4', $original->getIP() ); + } + +} diff --git a/tests/phpunit/includes/FauxRequestTest.php b/tests/phpunit/includes/FauxRequestTest.php new file mode 100644 index 0000000000..c054caa06f --- /dev/null +++ b/tests/phpunit/includes/FauxRequestTest.php @@ -0,0 +1,294 @@ +orgWgServer = $GLOBALS['wgServer']; + } + + public function tearDown() { + $GLOBALS['wgServer'] = $this->orgWgServer; + parent::tearDown(); + } + + /** + * @covers FauxRequest::__construct + */ + public function testConstructInvalidData() { + $this->setExpectedException( MWException::class, 'bogus data' ); + $req = new FauxRequest( 'x' ); + } + + /** + * @covers FauxRequest::__construct + */ + public function testConstructInvalidSession() { + $this->setExpectedException( MWException::class, 'bogus session' ); + $req = new FauxRequest( [], false, 'x' ); + } + + /** + * @covers FauxRequest::__construct + */ + public function testConstructWithSession() { + $session = SessionManager::singleton()->getEmptySession( new FauxRequest( [] ) ); + $this->assertInstanceOf( + FauxRequest::class, + new FauxRequest( [], false, $session ) + ); + } + + /** + * @covers FauxRequest::getText + */ + public function testGetText() { + $req = new FauxRequest( [ 'x' => 'Value' ] ); + $this->assertEquals( 'Value', $req->getText( 'x' ) ); + $this->assertEquals( '', $req->getText( 'z' ) ); + } + + /** + * Integration test for parent method + * @covers FauxRequest::getVal + */ + public function testGetVal() { + $req = new FauxRequest( [ 'crlf' => "A\r\nb" ] ); + $this->assertSame( "A\r\nb", $req->getVal( 'crlf' ), 'CRLF' ); + } + + /** + * Integration test for parent method + * @covers FauxRequest::getRawVal + */ + public function testGetRawVal() { + $req = new FauxRequest( [ + 'x' => 'Value', + 'y' => [ 'a' ], + 'crlf' => "A\r\nb" + ] ); + $this->assertSame( 'Value', $req->getRawVal( 'x' ) ); + $this->assertSame( null, $req->getRawVal( 'z' ), 'Not found' ); + $this->assertSame( null, $req->getRawVal( 'y' ), 'Array is ignored' ); + $this->assertSame( "A\r\nb", $req->getRawVal( 'crlf' ), 'CRLF' ); + } + + /** + * @covers FauxRequest::getValues + */ + public function testGetValues() { + $values = [ 'x' => 'Value', 'y' => '' ]; + $req = new FauxRequest( $values ); + $this->assertEquals( $values, $req->getValues() ); + } + + /** + * @covers FauxRequest::getQueryValues + */ + public function testGetQueryValues() { + $values = [ 'x' => 'Value', 'y' => '' ]; + + $req = new FauxRequest( $values ); + $this->assertEquals( $values, $req->getQueryValues() ); + $req = new FauxRequest( $values, /*wasPosted*/ true ); + $this->assertEquals( [], $req->getQueryValues() ); + } + + /** + * @covers FauxRequest::getMethod + */ + public function testGetMethod() { + $req = new FauxRequest( [] ); + $this->assertEquals( 'GET', $req->getMethod() ); + $req = new FauxRequest( [], /*wasPosted*/ true ); + $this->assertEquals( 'POST', $req->getMethod() ); + } + + /** + * @covers FauxRequest::wasPosted + */ + public function testWasPosted() { + $req = new FauxRequest( [] ); + $this->assertFalse( $req->wasPosted() ); + $req = new FauxRequest( [], /*wasPosted*/ true ); + $this->assertTrue( $req->wasPosted() ); + } + + /** + * @covers FauxRequest::getCookie + * @covers FauxRequest::setCookie + * @covers FauxRequest::setCookies + */ + public function testCookies() { + $req = new FauxRequest(); + $this->assertSame( null, $req->getCookie( 'z', '' ) ); + + $req->setCookie( 'x', 'Value', '' ); + $this->assertEquals( 'Value', $req->getCookie( 'x', '' ) ); + + $req->setCookies( [ 'x' => 'One', 'y' => 'Two' ], '' ); + $this->assertEquals( 'One', $req->getCookie( 'x', '' ) ); + $this->assertEquals( 'Two', $req->getCookie( 'y', '' ) ); + } + + /** + * @covers FauxRequest::getCookie + * @covers FauxRequest::setCookie + * @covers FauxRequest::setCookies + */ + public function testCookiesDefaultPrefix() { + global $wgCookiePrefix; + $oldPrefix = $wgCookiePrefix; + $wgCookiePrefix = '_'; + + $req = new FauxRequest(); + $this->assertSame( null, $req->getCookie( 'z' ) ); + + $req->setCookie( 'x', 'Value' ); + $this->assertEquals( 'Value', $req->getCookie( 'x' ) ); + + $wgCookiePrefix = $oldPrefix; + } + + /** + * @covers FauxRequest::getRequestURL + */ + public function testGetRequestURL_disallowed() { + $req = new FauxRequest(); + $this->setExpectedException( MWException::class ); + $req->getRequestURL(); + } + + /** + * @covers FauxRequest::setRequestURL + * @covers FauxRequest::getRequestURL + */ + public function testSetRequestURL() { + $req = new FauxRequest(); + $req->setRequestURL( 'https://example.org' ); + $this->assertEquals( 'https://example.org', $req->getRequestURL() ); + } + + /** + * @covers FauxRequest::getFullRequestURL + */ + public function testGetFullRequestURL_disallowed() { + $GLOBALS['wgServer'] = '//wiki.test'; + $req = new FauxRequest(); + + $this->setExpectedException( MWException::class ); + $req->getFullRequestURL(); + } + + /** + * @covers FauxRequest::getFullRequestURL + */ + public function testGetFullRequestURL_http() { + $GLOBALS['wgServer'] = '//wiki.test'; + $req = new FauxRequest(); + $req->setRequestURL( '/path' ); + + $this->assertSame( + 'http://wiki.test/path', + $req->getFullRequestURL() + ); + } + + /** + * @covers FauxRequest::getFullRequestURL + */ + public function testGetFullRequestURL_https() { + $GLOBALS['wgServer'] = '//wiki.test'; + $req = new FauxRequest( [], false, null, 'https' ); + $req->setRequestURL( '/path' ); + + $this->assertSame( + 'https://wiki.test/path', + $req->getFullRequestURL() + ); + } + + /** + * @covers FauxRequest::__construct + * @covers FauxRequest::getProtocol + */ + public function testProtocol() { + $req = new FauxRequest(); + $this->assertEquals( 'http', $req->getProtocol() ); + $req = new FauxRequest( [], false, null, 'http' ); + $this->assertEquals( 'http', $req->getProtocol() ); + $req = new FauxRequest( [], false, null, 'https' ); + $this->assertEquals( 'https', $req->getProtocol() ); + } + + /** + * @covers FauxRequest::setHeader + * @covers FauxRequest::setHeaders + * @covers FauxRequest::getHeader + */ + public function testGetSetHeader() { + $value = 'text/plain, text/html'; + + $request = new FauxRequest(); + $request->setHeader( 'Accept', $value ); + + $this->assertEquals( $request->getHeader( 'Nonexistent' ), false ); + $this->assertEquals( $request->getHeader( 'Accept' ), $value ); + $this->assertEquals( $request->getHeader( 'ACCEPT' ), $value ); + $this->assertEquals( $request->getHeader( 'accept' ), $value ); + $this->assertEquals( + $request->getHeader( 'Accept', WebRequest::GETHEADER_LIST ), + [ 'text/plain', 'text/html' ] + ); + } + + /** + * @covers FauxRequest::initHeaders + */ + public function testGetAllHeaders() { + $_SERVER['HTTP_TEST'] = 'Example'; + + $request = new FauxRequest(); + + $this->assertEquals( + [], + $request->getAllHeaders() + ); + + $this->assertEquals( + false, + $request->getHeader( 'test' ) + ); + } + + /** + * @covers FauxRequest::__construct + * @covers FauxRequest::getSessionArray + */ + public function testSessionData() { + $values = [ 'x' => 'Value', 'y' => '' ]; + + $req = new FauxRequest( [], false, /*session*/ $values ); + $this->assertEquals( $values, $req->getSessionArray() ); + + $req = new FauxRequest(); + $this->assertSame( null, $req->getSessionArray() ); + } + + /** + * @covers FauxRequest::getRawQueryString + * @covers FauxRequest::getRawPostString + * @covers FauxRequest::getRawInput + */ + public function testDummies() { + $req = new FauxRequest(); + $this->assertEquals( '', $req->getRawQueryString() ); + $this->assertEquals( '', $req->getRawPostString() ); + $this->assertEquals( '', $req->getRawInput() ); + } +} diff --git a/tests/phpunit/includes/FauxResponseTest.php b/tests/phpunit/includes/FauxResponseTest.php new file mode 100644 index 0000000000..8085bc710c --- /dev/null +++ b/tests/phpunit/includes/FauxResponseTest.php @@ -0,0 +1,146 @@ +response = new FauxResponse; + } + + /** + * @covers FauxResponse::setCookie + * @covers FauxResponse::getCookie + * @covers FauxResponse::getCookieData + * @covers FauxResponse::getCookies + */ + public function testCookie() { + $expire = time() + 100; + $cookie = [ + 'value' => 'val', + 'path' => '/path', + 'domain' => 'domain', + 'secure' => true, + 'httpOnly' => false, + 'raw' => false, + 'expire' => $expire, + ]; + + $this->assertEquals( null, $this->response->getCookie( 'xkey' ), 'Non-existing cookie' ); + $this->response->setCookie( 'key', 'val', $expire, [ + 'prefix' => 'x', + 'path' => '/path', + 'domain' => 'domain', + 'secure' => 1, + 'httpOnly' => 0, + ] ); + $this->assertEquals( 'val', $this->response->getCookie( 'xkey' ), 'Existing cookie' ); + $this->assertEquals( $cookie, $this->response->getCookieData( 'xkey' ), + 'Existing cookie (data)' ); + $this->assertEquals( [ 'xkey' => $cookie ], $this->response->getCookies(), + 'Existing cookies' ); + } + + /** + * @covers FauxResponse::getheader + * @covers FauxResponse::header + */ + public function testHeader() { + $this->assertEquals( null, $this->response->getHeader( 'Location' ), 'Non-existing header' ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getHeader( 'Location' ), + 'Set header' + ); + + $this->response->header( 'Location: http://127.0.0.1/' ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getHeader( 'Location' ), + 'Same header' + ); + + $this->response->header( 'Location: http://127.0.0.2/', false ); + $this->assertEquals( + 'http://127.0.0.1/', + $this->response->getHeader( 'Location' ), + 'Same header with override disabled' + ); + + $this->response->header( 'Location: http://localhost/' ); + $this->assertEquals( + 'http://localhost/', + $this->response->getHeader( 'LOCATION' ), + 'Get header case insensitive' + ); + } + + /** + * @covers FauxResponse::getStatusCode + */ + public function testResponseCode() { + $this->response->header( 'HTTP/1.1 200' ); + $this->assertEquals( 200, $this->response->getStatusCode(), 'Header with no message' ); + + $this->response->header( 'HTTP/1.x 201' ); + $this->assertEquals( + 201, + $this->response->getStatusCode(), + 'Header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.1 202 OK' ); + $this->assertEquals( 202, $this->response->getStatusCode(), 'Normal header' ); + + $this->response->header( 'HTTP/1.x 203 OK' ); + $this->assertEquals( + 203, + $this->response->getStatusCode(), + 'Normal header with no message and protocol 1.x' + ); + + $this->response->header( 'HTTP/1.x 204 OK', false, 205 ); + $this->assertEquals( + 205, + $this->response->getStatusCode(), + 'Third parameter overrides the HTTP/... header' + ); + + $this->response->statusHeader( 210 ); + $this->assertEquals( + 210, + $this->response->getStatusCode(), + 'Handle statusHeader method' + ); + + $this->response->header( 'Location: http://localhost/', false, 206 ); + $this->assertEquals( + 206, + $this->response->getStatusCode(), + 'Third parameter with another header' + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsInitializationTest.php b/tests/phpunit/includes/FormOptionsInitializationTest.php new file mode 100644 index 0000000000..2c78618aa1 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsInitializationTest.php @@ -0,0 +1,70 @@ +object = TestingAccessWrapper::newFromObject( new FormOptions() ); + } + + /** + * @covers FormOptions::add + */ + public function testAddStringOption() { + $this->object->add( 'foo', 'string value' ); + $this->assertEquals( + [ + 'foo' => [ + 'default' => 'string value', + 'consumed' => false, + 'type' => FormOptions::STRING, + 'value' => null, + ] + ], + $this->object->options + ); + } + + /** + * @covers FormOptions::add + */ + public function testAddIntegers() { + $this->object->add( 'one', 1 ); + $this->object->add( 'negone', -1 ); + $this->assertEquals( + [ + 'negone' => [ + 'default' => -1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ], + 'one' => [ + 'default' => 1, + 'value' => null, + 'consumed' => false, + 'type' => FormOptions::INT, + ] + ], + $this->object->options + ); + } +} diff --git a/tests/phpunit/includes/FormOptionsTest.php b/tests/phpunit/includes/FormOptionsTest.php new file mode 100644 index 0000000000..da08670f57 --- /dev/null +++ b/tests/phpunit/includes/FormOptionsTest.php @@ -0,0 +1,105 @@ +object = new FormOptions; + $this->object->add( 'string1', 'string one' ); + $this->object->add( 'string2', 'string two' ); + $this->object->add( 'integer', 0 ); + $this->object->add( 'float', 0.0 ); + $this->object->add( 'intnull', 0, FormOptions::INTNULL ); + } + + /** Helpers for testGuessType() */ + /* @{ */ + private function assertGuessBoolean( $data ) { + $this->guess( FormOptions::BOOL, $data ); + } + + private function assertGuessInt( $data ) { + $this->guess( FormOptions::INT, $data ); + } + + private function assertGuessFloat( $data ) { + $this->guess( FormOptions::FLOAT, $data ); + } + + private function assertGuessString( $data ) { + $this->guess( FormOptions::STRING, $data ); + } + + private function assertGuessArray( $data ) { + $this->guess( FormOptions::ARR, $data ); + } + + /** Generic helper */ + private function guess( $expected, $data ) { + $this->assertEquals( + $expected, + FormOptions::guessType( $data ) + ); + } + + /* @} */ + + /** + * Reuse helpers above assertGuessBoolean assertGuessInt assertGuessString + * @covers FormOptions::guessType + */ + public function testGuessTypeDetection() { + $this->assertGuessBoolean( true ); + $this->assertGuessBoolean( false ); + + $this->assertGuessInt( 0 ); + $this->assertGuessInt( -5 ); + $this->assertGuessInt( 5 ); + $this->assertGuessInt( 0x0F ); + + $this->assertGuessFloat( 0.0 ); + $this->assertGuessFloat( 1.5 ); + $this->assertGuessFloat( 1e3 ); + + $this->assertGuessString( 'true' ); + $this->assertGuessString( 'false' ); + $this->assertGuessString( '5' ); + $this->assertGuessString( '0' ); + $this->assertGuessString( '1.5' ); + + $this->assertGuessArray( [ 'foo' ] ); + } + + /** + * @expectedException MWException + * @covers FormOptions::guessType + */ + public function testGuessTypeOnNullThrowException() { + $this->object->guessType( null ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php b/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php new file mode 100644 index 0000000000..bb71610b64 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfAppendQueryTest.php @@ -0,0 +1,79 @@ +assertEquals( $expected, wfAppendQuery( $url, $query ), $message ); + } + + public static function provideAppendQuery() { + return [ + [ + 'http://www.example.org/index.php', + '', + 'http://www.example.org/index.php', + 'No query' + ], + [ + 'http://www.example.org/index.php', + [ 'foo' => 'bar' ], + 'http://www.example.org/index.php?foo=bar', + 'Set query array' + ], + [ + 'http://www.example.org/index.php?foz=baz', + 'foo=bar', + 'http://www.example.org/index.php?foz=baz&foo=bar', + 'Set query string' + ], + [ + 'http://www.example.org/index.php?foo=bar', + '', + 'http://www.example.org/index.php?foo=bar', + 'Empty string with query' + ], + [ + 'http://www.example.org/index.php?foo=bar', + [ 'baz' => 'quux' ], + 'http://www.example.org/index.php?foo=bar&baz=quux', + 'Add query array' + ], + [ + 'http://www.example.org/index.php?foo=bar', + 'baz=quux', + 'http://www.example.org/index.php?foo=bar&baz=quux', + 'Add query string' + ], + [ + 'http://www.example.org/index.php?foo=bar', + [ 'baz' => 'quux', 'foo' => 'baz' ], + 'http://www.example.org/index.php?foo=bar&baz=quux&foo=baz', + 'Modify query array' + ], + [ + 'http://www.example.org/index.php?foo=bar', + 'baz=quux&foo=baz', + 'http://www.example.org/index.php?foo=bar&baz=quux&foo=baz', + 'Modify query string' + ], + [ + 'http://www.example.org/index.php#baz', + 'foo=bar', + 'http://www.example.org/index.php?foo=bar#baz', + 'URL with fragment' + ], + [ + 'http://www.example.org/index.php?foo=bar#baz', + 'quux=blah', + 'http://www.example.org/index.php?foo=bar&quux=blah#baz', + 'URL with query string and fragment' + ] + ]; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php b/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php new file mode 100644 index 0000000000..65b56ef4b3 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfArrayPlus2dTest.php @@ -0,0 +1,94 @@ +assertEquals( + $expected, + wfArrayPlus2d( $baseArray, $newValues ), + $testName + ); + } + + /** + * Provider for testing wfArrayPlus2d + * + * @return array + */ + public static function provideArrays() { + return [ + // target array, new values array, expected result + [ + [ 0 => '1dArray' ], + [ 1 => '1dArray' ], + [ 0 => '1dArray', 1 => '1dArray' ], + "Test simple union of two arrays with different keys", + ], + [ + [ + 0 => [ 0 => '2dArray' ], + ], + [ + 0 => [ 1 => '2dArray' ], + ], + [ + 0 => [ 0 => '2dArray', 1 => '2dArray' ], + ], + "Test union of 2d arrays with different keys in the value array", + ], + [ + [ + 0 => [ 0 => '2dArray' ], + ], + [ + 0 => [ 0 => '1dArray' ], + ], + [ + 0 => [ 0 => '2dArray' ], + ], + "Test union of 2d arrays with same keys in the value array", + ], + [ + [ + 0 => [ 0 => [ 0 => '3dArray' ] ], + ], + [ + 0 => [ 0 => [ 1 => '2dArray' ] ], + ], + [ + 0 => [ 0 => [ 0 => '3dArray' ] ], + ], + "Test union of 3d array with different keys", + ], + [ + [ + 0 => [ 0 => [ 0 => '3dArray' ] ], + ], + [ + 0 => [ 1 => [ 0 => '2dArray' ] ], + ], + [ + 0 => [ 0 => [ 0 => '3dArray' ], 1 => [ 0 => '2dArray' ] ], + ], + "Test union of 3d array with different keys in the value array", + ], + [ + [ + 0 => [ 0 => [ 0 => '3dArray' ] ], + ], + [ + 0 => [ 0 => [ 0 => '2dArray' ] ], + ], + [ + 0 => [ 0 => [ 0 => '3dArray' ] ], + ], + "Test union of 3d array with same keys in the value array", + ], + ]; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php new file mode 100644 index 0000000000..7ddad369bd --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfAssembleUrlTest.php @@ -0,0 +1,112 @@ +assertEquals( + $output, + wfAssembleUrl( $parts ), + "Testing $partsDump assembles to $output" + ); + } + + /** + * Provider of URL parts for testing wfAssembleUrl() + * + * @return array + */ + public static function provideURLParts() { + $schemes = [ + '' => [], + '//' => [ + 'delimiter' => '//', + ], + 'http://' => [ + 'scheme' => 'http', + 'delimiter' => '://', + ], + ]; + + $hosts = [ + '' => [], + 'example.com' => [ + 'host' => 'example.com', + ], + 'example.com:123' => [ + 'host' => 'example.com', + 'port' => 123, + ], + 'id@example.com' => [ + 'user' => 'id', + 'host' => 'example.com', + ], + 'id@example.com:123' => [ + 'user' => 'id', + 'host' => 'example.com', + 'port' => 123, + ], + 'id:key@example.com' => [ + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + ], + 'id:key@example.com:123' => [ + 'user' => 'id', + 'pass' => 'key', + 'host' => 'example.com', + 'port' => 123, + ], + ]; + + $cases = []; + foreach ( $schemes as $scheme => $schemeParts ) { + foreach ( $hosts as $host => $hostParts ) { + foreach ( [ '', '/path' ] as $path ) { + foreach ( [ '', 'query' ] as $query ) { + foreach ( [ '', 'fragment' ] as $fragment ) { + $parts = array_merge( + $schemeParts, + $hostParts + ); + $url = $scheme . + $host . + $path; + + if ( $path ) { + $parts['path'] = $path; + } + if ( $query ) { + $parts['query'] = $query; + $url .= '?' . $query; + } + if ( $fragment ) { + $parts['fragment'] = $fragment; + $url .= '#' . $fragment; + } + + $cases[] = [ + $parts, + $url, + ]; + } + } + } + } + } + + $complexURL = 'http://id:key@example.org:321' . + '/over/there?name=ferret&foo=bar#nose'; + $cases[] = [ + wfParseUrl( $complexURL ), + $complexURL, + ]; + + return $cases; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php new file mode 100644 index 0000000000..78e09e60ba --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfBaseNameTest.php @@ -0,0 +1,40 @@ +assertEquals( $basename, wfBaseName( $fullpath ), + "wfBaseName('$fullpath') => '$basename'" ); + } + + public static function providePaths() { + return [ + [ '', '' ], + [ '/', '' ], + [ '\\', '' ], + [ '//', '' ], + [ '\\\\', '' ], + [ 'a', 'a' ], + [ 'aaaa', 'aaaa' ], + [ '/a', 'a' ], + [ '\\a', 'a' ], + [ '/aaaa', 'aaaa' ], + [ '\\aaaa', 'aaaa' ], + [ '/aaaa/', 'aaaa' ], + [ '\\aaaa\\', 'aaaa' ], + [ '\\aaaa\\', 'aaaa' ], + [ + '/mnt/upload3/wikipedia/en/thumb/8/8b/' + . 'Zork_Grand_Inquisitor_box_cover.jpg/93px-Zork_Grand_Inquisitor_box_cover.jpg', + '93px-Zork_Grand_Inquisitor_box_cover.jpg' + ], + [ 'C:\\Progra~1\\Wikime~1\\Wikipe~1\\VIEWER.EXE', 'VIEWER.EXE' ], + [ 'Östergötland_coat_of_arms.png', 'Östergötland_coat_of_arms.png' ], + ]; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php b/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php new file mode 100644 index 0000000000..7402054ea4 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfEscapeShellArgTest.php @@ -0,0 +1,43 @@ +assertEquals( $expected, $actual ); + } + + public function testMultipleArgs() { + if ( wfIsWindows() ) { + $expected = '"foo" "bar" "baz"'; + } else { + $expected = "'foo' 'bar' 'baz'"; + } + + $actual = wfEscapeShellArg( 'foo', 'bar', 'baz' ); + + $this->assertEquals( $expected, $actual ); + } + + public function testMultipleArgsAsArray() { + if ( wfIsWindows() ) { + $expected = '"foo" "bar" "baz"'; + } else { + $expected = "'foo' 'bar' 'baz'"; + } + + $actual = wfEscapeShellArg( [ 'foo', 'bar', 'baz' ] ); + + $this->assertEquals( $expected, $actual ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php new file mode 100644 index 0000000000..8a7bfa5a8c --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfGetCallerTest.php @@ -0,0 +1,46 @@ +assertEquals( 'WfGetCallerTest->testZero', wfGetCaller( 1 ) ); + } + + function callerOne() { + return wfGetCaller(); + } + + public function testOne() { + $this->assertEquals( 'WfGetCallerTest->testOne', self::callerOne() ); + } + + static function intermediateFunction( $level = 2, $n = 0 ) { + if ( $n > 0 ) { + return self::intermediateFunction( $level, $n - 1 ); + } + + return wfGetCaller( $level ); + } + + public function testTwo() { + $this->assertEquals( 'WfGetCallerTest->testTwo', self::intermediateFunction() ); + } + + public function testN() { + $this->assertEquals( 'WfGetCallerTest->testN', self::intermediateFunction( 2, 0 ) ); + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( 1, 0 ) + ); + + for ( $i = 0; $i < 10; $i++ ) { + $this->assertEquals( + 'WfGetCallerTest::intermediateFunction', + self::intermediateFunction( $i + 1, $i ) + ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php new file mode 100644 index 0000000000..eae5588b94 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfRemoveDotSegmentsTest.php @@ -0,0 +1,93 @@ +assertEquals( + $outputPath, + wfRemoveDotSegments( $inputPath ), + "Testing $inputPath expands to $outputPath" + ); + } + + /** + * Provider of URL paths for testing wfRemoveDotSegments() + * + * @return array + */ + public static function providePaths() { + return [ + [ '/a/b/c/./../../g', '/a/g' ], + [ 'mid/content=5/../6', 'mid/6' ], + [ '/a//../b', '/a/b' ], + [ '/.../a', '/.../a' ], + [ '.../a', '.../a' ], + [ '', '' ], + [ '/', '/' ], + [ '//', '//' ], + [ '.', '' ], + [ '..', '' ], + [ '...', '...' ], + [ '/.', '/' ], + [ '/..', '/' ], + [ './', '' ], + [ '../', '' ], + [ './a', 'a' ], + [ '../a', 'a' ], + [ '../../a', 'a' ], + [ '.././a', 'a' ], + [ './../a', 'a' ], + [ '././a', 'a' ], + [ '../../', '' ], + [ '.././', '' ], + [ './../', '' ], + [ '././', '' ], + [ '../..', '' ], + [ '../.', '' ], + [ './..', '' ], + [ './.', '' ], + [ '/../../a', '/a' ], + [ '/.././a', '/a' ], + [ '/./../a', '/a' ], + [ '/././a', '/a' ], + [ '/../../', '/' ], + [ '/.././', '/' ], + [ '/./../', '/' ], + [ '/././', '/' ], + [ '/../..', '/' ], + [ '/../.', '/' ], + [ '/./..', '/' ], + [ '/./.', '/' ], + [ 'b/../../a', '/a' ], + [ 'b/.././a', '/a' ], + [ 'b/./../a', '/a' ], + [ 'b/././a', 'b/a' ], + [ 'b/../../', '/' ], + [ 'b/.././', '/' ], + [ 'b/./../', '/' ], + [ 'b/././', 'b/' ], + [ 'b/../..', '/' ], + [ 'b/../.', '/' ], + [ 'b/./..', '/' ], + [ 'b/./.', 'b/' ], + [ '/b/../../a', '/a' ], + [ '/b/.././a', '/a' ], + [ '/b/./../a', '/a' ], + [ '/b/././a', '/b/a' ], + [ '/b/../../', '/' ], + [ '/b/.././', '/' ], + [ '/b/./../', '/' ], + [ '/b/././', '/b/' ], + [ '/b/../..', '/' ], + [ '/b/../.', '/' ], + [ '/b/./..', '/' ], + [ '/b/./.', '/b/' ], + ]; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php new file mode 100644 index 0000000000..6279cf6eee --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShellExecTest.php @@ -0,0 +1,20 @@ +assertEquals( 333333, strlen( $output ) ); + } + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php new file mode 100644 index 0000000000..40b2e636c9 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfShorthandToIntegerTest.php @@ -0,0 +1,31 @@ +assertEquals( + wfShorthandToInteger( $input ), + $output, + $description + ); + } + + public static function provideABunchOfShorthands() { + return [ + [ '', -1, 'Empty string' ], + [ ' ', -1, 'String of spaces' ], + [ '1G', 1024 * 1024 * 1024, 'One gig uppercased' ], + [ '1g', 1024 * 1024 * 1024, 'One gig lowercased' ], + [ '1M', 1024 * 1024, 'One meg uppercased' ], + [ '1m', 1024 * 1024, 'One meg lowercased' ], + [ '1K', 1024, 'One kb uppercased' ], + [ '1k', 1024, 'One kb lowercased' ], + ]; + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php b/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php new file mode 100644 index 0000000000..7f56b60529 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfStringToBoolTest.php @@ -0,0 +1,51 @@ +assertTrue( wfStringToBool( $str ) ); + } else { + $this->assertFalse( wfStringToBool( $str ) ); + } + } + +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php new file mode 100644 index 0000000000..a70f136a1c --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfTimestampTest.php @@ -0,0 +1,194 @@ +assertEquals( $output, wfTimestamp( $format, $input ), $desc ); + } + + public static function provideNormalTimestamps() { + $t = gmmktime( 12, 34, 56, 1, 15, 2001 ); + + return [ + // TS_UNIX + [ $t, TS_MW, '20010115123456', 'TS_UNIX to TS_MW' ], + [ -30281104, TS_MW, '19690115123456', 'Negative TS_UNIX to TS_MW' ], + [ $t, TS_UNIX, 979562096, 'TS_UNIX to TS_UNIX' ], + [ $t, TS_DB, '2001-01-15 12:34:56', 'TS_UNIX to TS_DB' ], + [ $t + 0.01, TS_MW, '20010115123456', 'TS_UNIX float to TS_MW' ], + + [ $t, TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_ISO_8601_BASIC to TS_DB' ], + + // TS_MW + [ '20010115123456', TS_MW, '20010115123456', 'TS_MW to TS_MW' ], + [ '20010115123456', TS_UNIX, 979562096, 'TS_MW to TS_UNIX' ], + [ '20010115123456', TS_DB, '2001-01-15 12:34:56', 'TS_MW to TS_DB' ], + [ '20010115123456', TS_ISO_8601_BASIC, '20010115T123456Z', 'TS_MW to TS_ISO_8601_BASIC' ], + + // TS_DB + [ '2001-01-15 12:34:56', TS_MW, '20010115123456', 'TS_DB to TS_MW' ], + [ '2001-01-15 12:34:56', TS_UNIX, 979562096, 'TS_DB to TS_UNIX' ], + [ '2001-01-15 12:34:56', TS_DB, '2001-01-15 12:34:56', 'TS_DB to TS_DB' ], + [ + '2001-01-15 12:34:56', + TS_ISO_8601_BASIC, + '20010115T123456Z', + 'TS_DB to TS_ISO_8601_BASIC' + ], + + # rfc2822 section 3.3 + [ '20010115123456', TS_RFC2822, 'Mon, 15 Jan 2001 12:34:56 GMT', 'TS_MW to TS_RFC2822' ], + [ 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ], + [ + ' Mon, 15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 with leading space to TS_MW' + ], + [ + '15 Jan 2001 12:34:56 GMT', + TS_MW, + '20010115123456', + 'TS_RFC2822 without optional day-of-week to TS_MW' + ], + + # FWS = ([*WSP CRLF] 1*WSP) / obs-FWS ; Folding white space + # obs-FWS = 1*WSP *(CRLF 1*WSP) ; Section 4.2 + [ 'Mon, 15 Jan 2001 12:34:56 GMT', TS_MW, '20010115123456', 'TS_RFC2822 to TS_MW' ], + + # WSP = SP / HTAB ; rfc2234 + [ + "Mon, 15 Jan\x092001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB to TS_MW' + ], + [ + "Mon, 15 Jan\x09 \x09 2001 12:34:56 GMT", + TS_MW, + '20010115123456', + 'TS_RFC2822 with HTAB and SP to TS_MW' + ], + [ + 'Sun, 6 Nov 94 08:49:37 GMT', + TS_MW, + '19941106084937', + 'TS_RFC2822 with obsolete year to TS_MW' + ], + ]; + } + + /** + * This test checks wfTimestamp() with values outside. + * It needs PHP 64 bits or PHP > 5.1. + * See r74778 and T27451 + * @dataProvider provideOldTimestamps + */ + public function testOldTimestamps( $input, $outputType, $output, $message ) { + $timestamp = wfTimestamp( $outputType, $input ); + if ( substr( $output, 0, 1 ) === '/' ) { + // T66946: Day of the week calculations for very old + // timestamps varies from system to system. + $this->assertRegExp( $output, $timestamp, $message ); + } else { + $this->assertEquals( $output, $timestamp, $message ); + } + } + + public static function provideOldTimestamps() { + return [ + [ + '19011213204554', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:54 GMT', + 'Earliest time according to PHP documentation' + ], + [ '20380119031407', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:07 GMT', 'Latest 32 bit time' ], + [ '19011213204552', TS_UNIX, '-2147483648', 'Earliest 32 bit unix time' ], + [ '20380119031407', TS_UNIX, '2147483647', 'Latest 32 bit unix time' ], + [ '19011213204552', TS_RFC2822, 'Fri, 13 Dec 1901 20:45:52 GMT', 'Earliest 32 bit time' ], + [ + '19011213204551', + TS_RFC2822, + 'Fri, 13 Dec 1901 20:45:51 GMT', 'Earliest 32 bit time - 1' + ], + [ '20380119031408', TS_RFC2822, 'Tue, 19 Jan 2038 03:14:08 GMT', 'Latest 32 bit time + 1' ], + [ '19011212000000', TS_MW, '19011212000000', 'Convert to itself r74778#c10645' ], + [ '19011213204551', TS_UNIX, '-2147483649', 'Earliest 32 bit unix time - 1' ], + [ '20380119031408', TS_UNIX, '2147483648', 'Latest 32 bit unix time + 1' ], + [ '-2147483649', TS_MW, '19011213204551', '1901 negative unix time to MediaWiki' ], + [ '-5331871504', TS_MW, '18010115123456', '1801 negative unix time to MediaWiki' ], + [ + '0117-08-09 12:34:56', + TS_RFC2822, + '/, 09 Aug 0117 12:34:56 GMT$/', + 'Death of Roman Emperor [[Trajan]]' + ], + + /* @todo FIXME: 00 to 101 years are taken as being in [1970-2069] */ + [ '-58979923200', TS_RFC2822, '/, 01 Jan 0101 00:00:00 GMT$/', '1/1/101' ], + [ '-62135596800', TS_RFC2822, 'Mon, 01 Jan 0001 00:00:00 GMT', 'Year 1' ], + + /* It is not clear if we should generate a year 0 or not + * We are completely off RFC2822 requirement of year being + * 1900 or later. + */ + [ + '-62142076800', + TS_RFC2822, + 'Wed, 18 Oct 0000 00:00:00 GMT', + 'ISO 8601:2004 [[year 0]], also called [[1 BC]]' + ], + ]; + } + + /** + * @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1 + * @dataProvider provideHttpDates + */ + public function testHttpDate( $input, $output, $desc ) { + $this->assertEquals( $output, wfTimestamp( TS_MW, $input ), $desc ); + } + + public static function provideHttpDates() { + return [ + [ 'Sun, 06 Nov 1994 08:49:37 GMT', '19941106084937', 'RFC 822 date' ], + [ 'Sunday, 06-Nov-94 08:49:37 GMT', '19941106084937', 'RFC 850 date' ], + [ 'Sun Nov 6 08:49:37 1994', '19941106084937', "ANSI C's asctime() format" ], + // See http://www.squid-cache.org/mail-archive/squid-users/200307/0122.html and r77171 + [ + 'Mon, 22 Nov 2010 14:12:42 GMT; length=52626', + '20101122141242', + 'Netscape extension to HTTP/1.0' + ], + ]; + } + + /** + * There are a number of assumptions in our codebase where wfTimestamp() + * should give the current date but it is not given a 0 there. See r71751 CR + */ + public function testTimestampParameter() { + $now = wfTimestamp( TS_UNIX ); + // We check that wfTimestamp doesn't return false (error) and use a LessThan assert + // for the cases where the test is run in a second boundary. + + $zero = wfTimestamp( TS_UNIX, 0 ); + $this->assertNotEquals( false, $zero ); + $this->assertLessThan( 5, $zero - $now ); + + $empty = wfTimestamp( TS_UNIX, '' ); + $this->assertNotEquals( false, $empty ); + $this->assertLessThan( 5, $empty - $now ); + + $null = wfTimestamp( TS_UNIX, null ); + $this->assertNotEquals( false, $null ); + $this->assertLessThan( 5, $null - $now ); + } +} diff --git a/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php new file mode 100644 index 0000000000..5d9f63daa7 --- /dev/null +++ b/tests/phpunit/includes/GlobalFunctions/wfUrlencodeTest.php @@ -0,0 +1,123 @@ +verifyEncodingFor( 'Apache', $input, $expected ); + } + + /** + * @dataProvider provideURLS + */ + public function testEncodingUrlWithMicrosoftIis7( $input, $expected ) { + $this->verifyEncodingFor( 'Microsoft-IIS/7', $input, $expected ); + } + + # ### HELPERS ############################################################# + + /** + * Internal helper that actually run the test. + * Called by the public methods testEncodingUrlWith...() + */ + private function verifyEncodingFor( $server, $input, $expectations ) { + $expected = $this->extractExpect( $server, $expectations ); + + // save up global + $old = $_SERVER['SERVER_SOFTWARE'] ?? null; + $_SERVER['SERVER_SOFTWARE'] = $server; + wfUrlencode( null ); + + // do the requested test + $this->assertEquals( + $expected, + wfUrlencode( $input ), + "Encoding '$input' for server '$server' should be '$expected'" + ); + + // restore global + if ( $old === null ) { + unset( $_SERVER['SERVER_SOFTWARE'] ); + } else { + $_SERVER['SERVER_SOFTWARE'] = $old; + } + wfUrlencode( null ); + } + + /** + * Interprets the provider array. Return expected value depending + * the HTTP server name. + */ + private function extractExpect( $server, $expectations ) { + if ( is_string( $expectations ) ) { + return $expectations; + } elseif ( is_array( $expectations ) ) { + if ( !array_key_exists( $server, $expectations ) ) { + throw new MWException( __METHOD__ . " expectation does not have any " + . "value for server name $server. Check the provider array.\n" ); + } else { + return $expectations[$server]; + } + } else { + throw new MWException( __METHOD__ . " given invalid expectation for " + . "'$server'. Should be a string or an array( => ).\n" ); + } + } + + # ### PROVIDERS ########################################################### + + /** + * Format is either: + * [ 'input', 'expected' ]; + * Or: + * [ 'input', + * [ 'Apache', 'expected' ], + * [ 'Microsoft-IIS/7', 'expected' ], + * ], + * If you want to add other HTTP server name, you will have to add a new + * testing method much like the testEncodingUrlWith() method above. + */ + public static function provideURLS() { + return [ + # ## RFC 1738 chars + // + is not safe + [ '+', '%2B' ], + // & and = not safe in queries + [ '&', '%26' ], + [ '=', '%3D' ], + + [ ':', [ + 'Apache' => ':', + 'Microsoft-IIS/7' => '%3A', + ] ], + + // remaining chars do not need encoding + [ + ';@$-_.!*', + ';@$-_.!*', + ], + + # ## Other tests + // slash remain unchanged. %2F seems to break things + [ '/', '/' ], + // T105265 + [ '~', '~' ], + + // Other 'funnies' chars + [ '[]', '%5B%5D' ], + [ '<>', '%3C%3E' ], + + // Apostrophe is encoded + [ '\'', '%27' ], + ]; + } +} diff --git a/tests/phpunit/includes/HooksTest.php b/tests/phpunit/includes/HooksTest.php new file mode 100644 index 0000000000..c66b712b83 --- /dev/null +++ b/tests/phpunit/includes/HooksTest.php @@ -0,0 +1,332 @@ +assertSame( $expectedFoo, $foo, $msg ); + $this->assertSame( $expectedBar, $bar, $msg ); + } + + /** + * @covers Hooks::getHandlers + */ + public function testGetHandlers() { + global $wgHooks; + + $this->assertSame( + [], + Hooks::getHandlers( 'MediaWikiHooksTest001' ), + 'No hooks registered' + ); + + $a = new NothingClass(); + $b = new NothingClass(); + + $wgHooks['MediaWikiHooksTest001'][] = $a; + + $this->assertSame( + [ $a ], + Hooks::getHandlers( 'MediaWikiHooksTest001' ), + 'Hook registered by $wgHooks' + ); + + Hooks::register( 'MediaWikiHooksTest001', $b ); + $this->assertSame( + [ $b, $a ], + Hooks::getHandlers( 'MediaWikiHooksTest001' ), + 'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register' + ); + + Hooks::clear( 'MediaWikiHooksTest001' ); + unset( $wgHooks['MediaWikiHooksTest001'] ); + + Hooks::register( 'MediaWikiHooksTest001', $b ); + $this->assertSame( + [ $b ], + Hooks::getHandlers( 'MediaWikiHooksTest001' ), + 'Hook registered by Hook::register' + ); + } + + /** + * @covers Hooks::isRegistered + * @covers Hooks::register + * @covers Hooks::run + * @covers Hooks::callHook + */ + public function testNewStyleHookInteraction() { + global $wgHooks; + + $a = new NothingClass(); + $b = new NothingClass(); + + $wgHooks['MediaWikiHooksTest001'][] = $a; + $this->assertTrue( + Hooks::isRegistered( 'MediaWikiHooksTest001' ), + 'Hook registered via $wgHooks should be noticed by Hooks::isRegistered' + ); + + Hooks::register( 'MediaWikiHooksTest001', $b ); + $this->assertEquals( + 2, + count( Hooks::getHandlers( 'MediaWikiHooksTest001' ) ), + 'Hooks::getHandlers() should return hooks registered via wgHooks as well as Hooks::register' + ); + + $foo = 'quux'; + $bar = 'qaax'; + + Hooks::run( 'MediaWikiHooksTest001', [ &$foo, &$bar ] ); + $this->assertEquals( + 1, + $a->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + $this->assertEquals( + 1, + $b->calls, + 'Hooks::run() should run hooks registered via wgHooks as well as Hooks::register' + ); + } + + /** + * @expectedException MWException + * @covers Hooks::run + * @covers Hooks::callHook + */ + public function testUncallableFunction() { + Hooks::register( 'MediaWikiHooksTest001', 'ThisFunctionDoesntExist' ); + Hooks::run( 'MediaWikiHooksTest001', [] ); + } + + /** + * @covers Hooks::run + * @covers Hooks::callHook + */ + public function testFalseReturn() { + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + return false; + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + + return true; + } ); + $foo = 'original'; + Hooks::run( 'MediaWikiHooksTest001', [ &$foo ] ); + $this->assertSame( 'original', $foo, 'Hooks abort after a false return.' ); + } + + /** + * @covers Hooks::run + */ + public function testNullReturn() { + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + return; + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + + return true; + } ); + $foo = 'original'; + Hooks::run( 'MediaWikiHooksTest001', [ &$foo ] ); + $this->assertSame( 'test', $foo, 'Hooks continue after a null return.' ); + } + + /** + * @covers Hooks::callHook + */ + public function testCallHook_FalseHook() { + Hooks::register( 'MediaWikiHooksTest001', false ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + + return true; + } ); + $foo = 'original'; + Hooks::run( 'MediaWikiHooksTest001', [ &$foo ] ); + $this->assertSame( 'test', $foo, 'Hooks that are falsey are skipped.' ); + } + + /** + * @covers Hooks::callHook + * @expectedException MWException + */ + public function testCallHook_UnknownDatatype() { + Hooks::register( 'MediaWikiHooksTest001', 12345 ); + Hooks::run( 'MediaWikiHooksTest001' ); + } + + /** + * @covers Hooks::callHook + * @expectedException PHPUnit_Framework_Error_Deprecated + */ + public function testCallHook_Deprecated() { + Hooks::register( 'MediaWikiHooksTest001', 'NothingClass::someStatic' ); + Hooks::run( 'MediaWikiHooksTest001', [], '1.31' ); + } + + /** + * @covers Hooks::runWithoutAbort + * @covers Hooks::callHook + */ + public function testRunWithoutAbort() { + $list = []; + Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) { + $list[] = 1; + return true; // Explicit true + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) { + $list[] = 2; + return; // Implicit null + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$list ) { + $list[] = 3; + // No return + } ); + + Hooks::runWithoutAbort( 'MediaWikiHooksTest001', [ &$list ] ); + $this->assertSame( [ 1, 2, 3 ], $list, 'All hooks ran.' ); + } + + /** + * @covers Hooks::runWithoutAbort + * @covers Hooks::callHook + */ + public function testRunWithoutAbortWarning() { + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + return false; + } ); + Hooks::register( 'MediaWikiHooksTest001', function ( &$foo ) { + $foo = 'test'; + return true; + } ); + $foo = 'original'; + + $this->setExpectedException( + UnexpectedValueException::class, + 'Invalid return from hook-MediaWikiHooksTest001-closure for ' . + 'unabortable MediaWikiHooksTest001' + ); + Hooks::runWithoutAbort( 'MediaWikiHooksTest001', [ &$foo ] ); + } + + /** + * @expectedException FatalError + * @covers Hooks::run + */ + public function testFatalError() { + Hooks::register( 'MediaWikiHooksTest001', function () { + return 'test'; + } ); + Hooks::run( 'MediaWikiHooksTest001', [] ); + } +} + +function NothingFunction( &$foo, &$bar ) { + $foo = 'changed-func'; + + return true; +} + +function NothingFunctionData( $data, &$foo, &$bar ) { + $foo = $data; + + return true; +} + +class NothingClass { + public $calls = 0; + + public static function someStatic( &$foo, &$bar ) { + $foo = 'changed-static'; + + return true; + } + + public function someNonStatic( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-nonstatic'; + $bar = 'changed-nonstatic'; + + return true; + } + + public function onMediaWikiHooksTest001( &$foo, &$bar ) { + $this->calls++; + $foo = 'changed-onevent'; + + return true; + } + + public function someNonStaticWithData( $data, &$foo, &$bar ) { + $this->calls++; + $foo = $data; + + return true; + } +} diff --git a/tests/phpunit/includes/LicensesTest.php b/tests/phpunit/includes/LicensesTest.php new file mode 100644 index 0000000000..0e96bf44ee --- /dev/null +++ b/tests/phpunit/includes/LicensesTest.php @@ -0,0 +1,25 @@ + 'FooField', + 'type' => 'select', + 'section' => 'description', + 'id' => 'wpLicense', + 'label' => 'A label text', # Note can't test label-message because $wgOut is not defined + 'name' => 'AnotherName', + 'licenses' => $str, + ] ); + $this->assertThat( $lc, $this->isInstanceOf( Licenses::class ) ); + } +} diff --git a/tests/phpunit/includes/ListToggleTest.php b/tests/phpunit/includes/ListToggleTest.php new file mode 100644 index 0000000000..3574545e45 --- /dev/null +++ b/tests/phpunit/includes/ListToggleTest.php @@ -0,0 +1,49 @@ +getMockBuilder( OutputPage::class ) + ->setMethods( null ) + ->disableOriginalConstructor() + ->getMock(); + + $listToggle = new ListToggle( $output ); + + $this->assertInstanceOf( ListToggle::class, $listToggle ); + $this->assertContains( 'mediawiki.checkboxtoggle', $output->getModules() ); + $this->assertContains( 'mediawiki.checkboxtoggle.styles', $output->getModuleStyles() ); + } + + /** + * @covers ListToggle::getHTML + */ + public function testGetHTML() { + $output = $this->createMock( OutputPage::class ); + $output->expects( $this->any() ) + ->method( 'msg' ) + ->will( $this->returnCallback( function ( $key ) { + return wfMessage( $key )->inLanguage( 'qqx' ); + } ) ); + $output->expects( $this->once() ) + ->method( 'getLanguage' ) + ->will( $this->returnValue( Language::factory( 'qqx' ) ) ); + + $listToggle = new ListToggle( $output ); + + $html = $listToggle->getHTML(); + $this->assertEquals( '
' . + '(checkbox-select: (checkbox-all)(comma-separator)' . + '' . + '(checkbox-none)(comma-separator)(checkbox-invert))
', + $html ); + } +} diff --git a/tests/phpunit/includes/MagicWordFactoryTest.php b/tests/phpunit/includes/MagicWordFactoryTest.php new file mode 100644 index 0000000000..065024bd73 --- /dev/null +++ b/tests/phpunit/includes/MagicWordFactoryTest.php @@ -0,0 +1,84 @@ +makeMagicWordFactory( $contLang ); + $magicWordContLang = $magicWordFactory->getContentLanguage(); + + $this->assertSame( $contLang, $magicWordContLang ); + } + + public function testGetMagicWord() { + $magicWordIdValid = 'pageid'; + $magicWordFactory = $this->makeMagicWordFactory(); + $mwActual = $magicWordFactory->get( $magicWordIdValid ); + $contLang = $magicWordFactory->getContentLanguage(); + $expected = new MagicWord( $magicWordIdValid, [ 'PAGEID' ], false, $contLang ); + + $this->assertEquals( $expected, $mwActual ); + } + + public function testGetInvalidMagicWord() { + $magicWordFactory = $this->makeMagicWordFactory(); + + $this->setExpectedException( MWException::class ); + \Wikimedia\suppressWarnings(); + try { + $magicWordFactory->get( 'invalid magic word' ); + } finally { + \Wikimedia\restoreWarnings(); + } + } + + public function testGetVariableIDs() { + $magicWordFactory = $this->makeMagicWordFactory(); + $varIds = $magicWordFactory->getVariableIDs(); + + $this->assertInternalType( 'array', $varIds ); + $this->assertNotEmpty( $varIds ); + $this->assertContainsOnly( 'string', $varIds ); + } + + public function testGetSubstIDs() { + $magicWordFactory = $this->makeMagicWordFactory(); + $substIds = $magicWordFactory->getSubstIDs(); + + $this->assertInternalType( 'array', $substIds ); + $this->assertNotEmpty( $substIds ); + $this->assertContainsOnly( 'string', $substIds ); + } + + /** + * Test both valid and invalid caching hints paths + */ + public function testGetCacheTTL() { + $magicWordFactory = $this->makeMagicWordFactory(); + $actual = $magicWordFactory->getCacheTTL( 'localday' ); + + $this->assertSame( 3600, $actual ); + + $actual = $magicWordFactory->getCacheTTL( 'currentmonth' ); + $this->assertSame( 86400, $actual ); + + $actual = $magicWordFactory->getCacheTTL( 'invalid' ); + $this->assertSame( -1, $actual ); + } + + public function testGetDoubleUnderscoreArray() { + $magicWordFactory = $this->makeMagicWordFactory(); + $actual = $magicWordFactory->getDoubleUnderscoreArray(); + + $this->assertInstanceOf( MagicWordArray::class, $actual ); + } +} diff --git a/tests/phpunit/includes/MediaWikiServicesTest.php b/tests/phpunit/includes/MediaWikiServicesTest.php new file mode 100644 index 0000000000..8fa0cd60ec --- /dev/null +++ b/tests/phpunit/includes/MediaWikiServicesTest.php @@ -0,0 +1,372 @@ +set( 'ServiceWiringFiles', $globalConfig->get( 'ServiceWiringFiles' ) ); + $testConfig->set( 'ConfigRegistry', $globalConfig->get( 'ConfigRegistry' ) ); + + return $testConfig; + } + + /** + * @return MediaWikiServices + */ + private function newMediaWikiServices( Config $config = null ) { + if ( $config === null ) { + $config = $this->newTestConfig(); + } + + $instance = new MediaWikiServices( $config ); + + // Load the default wiring from the specified files. + $wiringFiles = $config->get( 'ServiceWiringFiles' ); + $instance->loadWiringFiles( $wiringFiles ); + + return $instance; + } + + public function testGetInstance() { + $services = MediaWikiServices::getInstance(); + $this->assertInstanceOf( MediaWikiServices::class, $services ); + } + + public function testForceGlobalInstance() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $this->assertInstanceOf( MediaWikiServices::class, $oldServices ); + $this->assertNotSame( $oldServices, $newServices ); + + $theServices = MediaWikiServices::getInstance(); + $this->assertSame( $theServices, $newServices ); + + MediaWikiServices::forceGlobalInstance( $oldServices ); + + $theServices = MediaWikiServices::getInstance(); + $this->assertSame( $theServices, $oldServices ); + } + + public function testResetGlobalInstance() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $service1 = $this->createMock( SalvageableService::class ); + $service1->expects( $this->never() ) + ->method( 'salvage' ); + + $newServices->defineService( + 'Test', + function () use ( $service1 ) { + return $service1; + } + ); + + // force instantiation + $newServices->getService( 'Test' ); + + MediaWikiServices::resetGlobalInstance( $this->newTestConfig() ); + $theServices = MediaWikiServices::getInstance(); + + $this->assertSame( + $service1, + $theServices->getService( 'Test' ), + 'service definition should survive reset' + ); + + $this->assertNotSame( $theServices, $newServices ); + $this->assertNotSame( $theServices, $oldServices ); + + MediaWikiServices::forceGlobalInstance( $oldServices ); + } + + public function testResetGlobalInstance_quick() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $service1 = $this->createMock( SalvageableService::class ); + $service1->expects( $this->never() ) + ->method( 'salvage' ); + + $service2 = $this->createMock( SalvageableService::class ); + $service2->expects( $this->once() ) + ->method( 'salvage' ) + ->with( $service1 ); + + // sequence of values the instantiator will return + $instantiatorReturnValues = [ + $service1, + $service2, + ]; + + $newServices->defineService( + 'Test', + function () use ( &$instantiatorReturnValues ) { + return array_shift( $instantiatorReturnValues ); + } + ); + + // force instantiation + $newServices->getService( 'Test' ); + + MediaWikiServices::resetGlobalInstance( $this->newTestConfig(), 'quick' ); + $theServices = MediaWikiServices::getInstance(); + + $this->assertSame( $service2, $theServices->getService( 'Test' ) ); + + $this->assertNotSame( $theServices, $newServices ); + $this->assertNotSame( $theServices, $oldServices ); + + MediaWikiServices::forceGlobalInstance( $oldServices ); + } + + public function testDisableStorageBackend() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $lbFactory = $this->getMockBuilder( \Wikimedia\Rdbms\LBFactorySimple::class ) + ->disableOriginalConstructor() + ->getMock(); + + $newServices->redefineService( + 'DBLoadBalancerFactory', + function () use ( $lbFactory ) { + return $lbFactory; + } + ); + + // force the service to become active, so we can check that it does get destroyed + $newServices->getService( 'DBLoadBalancerFactory' ); + + MediaWikiServices::disableStorageBackend(); // should destroy DBLoadBalancerFactory + + try { + MediaWikiServices::getInstance()->getService( 'DBLoadBalancerFactory' ); + $this->fail( 'DBLoadBalancerFactory should have been disabled' ); + } + catch ( ServiceDisabledException $ex ) { + // ok, as expected + } catch ( Throwable $ex ) { + $this->fail( 'ServiceDisabledException expected, caught ' . get_class( $ex ) ); + } + + MediaWikiServices::forceGlobalInstance( $oldServices ); + $newServices->destroy(); + + // No exception was thrown, avoid being risky + $this->assertTrue( true ); + } + + public function testResetChildProcessServices() { + $newServices = $this->newMediaWikiServices(); + $oldServices = MediaWikiServices::forceGlobalInstance( $newServices ); + + $service1 = $this->createMock( DestructibleService::class ); + $service1->expects( $this->once() ) + ->method( 'destroy' ); + + $service2 = $this->createMock( DestructibleService::class ); + $service2->expects( $this->never() ) + ->method( 'destroy' ); + + // sequence of values the instantiator will return + $instantiatorReturnValues = [ + $service1, + $service2, + ]; + + $newServices->defineService( + 'Test', + function () use ( &$instantiatorReturnValues ) { + return array_shift( $instantiatorReturnValues ); + } + ); + + // force the service to become active, so we can check that it does get destroyed + $oldTestService = $newServices->getService( 'Test' ); + + MediaWikiServices::resetChildProcessServices(); + $finalServices = MediaWikiServices::getInstance(); + + $newTestService = $finalServices->getService( 'Test' ); + $this->assertNotSame( $oldTestService, $newTestService ); + + MediaWikiServices::forceGlobalInstance( $oldServices ); + } + + public function testResetServiceForTesting() { + $services = $this->newMediaWikiServices(); + $serviceCounter = 0; + + $services->defineService( + 'Test', + function () use ( &$serviceCounter ) { + $serviceCounter++; + $service = $this->createMock( Wikimedia\Services\DestructibleService::class ); + $service->expects( $this->once() )->method( 'destroy' ); + return $service; + } + ); + + // This should do nothing. In particular, it should not create a service instance. + $services->resetServiceForTesting( 'Test' ); + $this->assertEquals( 0, $serviceCounter, 'No service instance should be created yet.' ); + + $oldInstance = $services->getService( 'Test' ); + $this->assertEquals( 1, $serviceCounter, 'A service instance should exit now.' ); + + // The old instance should be detached, and destroy() called. + $services->resetServiceForTesting( 'Test' ); + $newInstance = $services->getService( 'Test' ); + + $this->assertNotSame( $oldInstance, $newInstance ); + + // Satisfy the expectation that destroy() is called also for the second service instance. + $newInstance->destroy(); + } + + public function testResetServiceForTesting_noDestroy() { + $services = $this->newMediaWikiServices(); + + $services->defineService( + 'Test', + function () { + $service = $this->createMock( Wikimedia\Services\DestructibleService::class ); + $service->expects( $this->never() )->method( 'destroy' ); + return $service; + } + ); + + $oldInstance = $services->getService( 'Test' ); + + // The old instance should be detached, but destroy() not called. + $services->resetServiceForTesting( 'Test', false ); + $newInstance = $services->getService( 'Test' ); + + $this->assertNotSame( $oldInstance, $newInstance ); + } + + public function provideGetters() { + $getServiceCases = $this->provideGetService(); + $getterCases = []; + + // All getters should be named just like the service, with "get" added. + foreach ( $getServiceCases as $name => $case ) { + if ( $name[0] === '_' ) { + // Internal service, no getter + continue; + } + list( $service, $class ) = $case; + $getterCases[$name] = [ + 'get' . $service, + $class, + in_array( $service, $this->deprecatedServices ) + ]; + } + + return $getterCases; + } + + /** + * @dataProvider provideGetters + */ + public function testGetters( $getter, $type, $isDeprecated = false ) { + if ( $isDeprecated ) { + $this->hideDeprecated( MediaWikiServices::class . "::$getter" ); + } + + // Test against the default instance, since the dummy will not know the default services. + $services = MediaWikiServices::getInstance(); + $service = $services->$getter(); + $this->assertInstanceOf( $type, $service ); + } + + public function provideGetService() { + global $IP; + $serviceList = require "$IP/includes/ServiceWiring.php"; + $ret = []; + foreach ( $serviceList as $name => $callback ) { + $fun = new ReflectionFunction( $callback ); + if ( !$fun->hasReturnType() ) { + throw new MWException( 'All service callbacks must have a return type defined, ' . + "none found for $name" ); + } + $ret[$name] = [ $name, $fun->getReturnType()->__toString() ]; + } + return $ret; + } + + /** + * @dataProvider provideGetService + */ + public function testGetService( $name, $type ) { + // Test against the default instance, since the dummy will not know the default services. + $services = MediaWikiServices::getInstance(); + + $service = $services->getService( $name ); + $this->assertInstanceOf( $type, $service ); + } + + public function testDefaultServiceInstantiation() { + // Check all services in the default instance, not a dummy instance! + // Note that we instantiate all services here, including any that + // were registered by extensions. + $services = MediaWikiServices::getInstance(); + $names = $services->getServiceNames(); + + foreach ( $names as $name ) { + $this->assertTrue( $services->hasService( $name ) ); + $service = $services->getService( $name ); + $this->assertInternalType( 'object', $service ); + } + } + + public function testDefaultServiceWiringServicesHaveTests() { + global $IP; + $testedServices = array_keys( $this->provideGetService() ); + $allServices = array_keys( require "$IP/includes/ServiceWiring.php" ); + $this->assertEquals( + [], + array_diff( $allServices, $testedServices ), + 'The following services have not been added to MediaWikiServicesTest::provideGetService' + ); + } + + public function testGettersAreSorted() { + $methods = ( new ReflectionClass( MediaWikiServices::class ) ) + ->getMethods( ReflectionMethod::IS_STATIC | ReflectionMethod::IS_PUBLIC ); + + $names = array_map( function ( $method ) { + return $method->getName(); + }, $methods ); + $serviceNames = array_map( function ( $name ) { + return "get$name"; + }, array_keys( $this->provideGetService() ) ); + $names = array_values( array_filter( $names, function ( $name ) use ( $serviceNames ) { + return in_array( $name, $serviceNames ); + } ) ); + + $sortedNames = $names; + natcasesort( $sortedNames ); + + $this->assertSame( $sortedNames, $names, + 'Please keep service getters sorted alphabetically' ); + } +} diff --git a/tests/phpunit/includes/MediaWikiVersionFetcherTest.php b/tests/phpunit/includes/MediaWikiVersionFetcherTest.php new file mode 100644 index 0000000000..9803081869 --- /dev/null +++ b/tests/phpunit/includes/MediaWikiVersionFetcherTest.php @@ -0,0 +1,21 @@ + + */ +class MediaWikiVersionFetcherTest extends MediaWikiTestCase { + + public function testReturnsResult() { + global $wgVersion; + $versionFetcher = new MediaWikiVersionFetcher(); + $this->assertSame( $wgVersion, $versionFetcher->fetchVersion() ); + } + +} diff --git a/tests/phpunit/includes/PathRouterTest.php b/tests/phpunit/includes/PathRouterTest.php new file mode 100644 index 0000000000..d8916751c0 --- /dev/null +++ b/tests/phpunit/includes/PathRouterTest.php @@ -0,0 +1,325 @@ +add( "/wiki/$1" ); + $this->basicRouter = $router; + } + + public static function provideParse() { + $tests = [ + // Basic path parsing + 'Basic path parsing' => [ + "/wiki/$1", + "/wiki/Foo", + [ 'title' => "Foo" ] + ], + // + 'Loose path auto-$1: /$1' => [ + "/", + "/Foo", + [ 'title' => "Foo" ] + ], + 'Loose path auto-$1: /wiki' => [ + "/wiki", + "/wiki/Foo", + [ 'title' => "Foo" ] + ], + 'Loose path auto-$1: /wiki/' => [ + "/wiki/", + "/wiki/Foo", + [ 'title' => "Foo" ] + ], + // Ensure that path is based on specificity, not order + 'Order, /$1 added first' => [ + [ "/$1", "/a/$1", "/b/$1" ], + "/a/Foo", + [ 'title' => "Foo" ] + ], + 'Order, /$1 added last' => [ + [ "/b/$1", "/a/$1", "/$1" ], + "/a/Foo", + [ 'title' => "Foo" ] + ], + // Handling of key based arrays with a url parameter + 'Key based array' => [ + [ [ + 'path' => [ 'edit' => "/edit/$1" ], + 'params' => [ 'action' => '$key' ], + ] ], + "/edit/Foo", + [ 'title' => "Foo", 'action' => 'edit' ] + ], + // Additional parameter + 'Basic $2' => [ + [ [ + 'path' => '/$2/$1', + 'params' => [ 'test' => '$2' ] + ] ], + "/asdf/Foo", + [ 'title' => "Foo", 'test' => 'asdf' ] + ], + ]; + // Shared patterns for restricted value parameter tests + $restrictedPatterns = [ + [ + 'path' => '/$2/$1', + 'params' => [ 'test' => '$2' ], + 'options' => [ '$2' => [ 'a', 'b' ] ] + ], + [ + 'path' => '/$2/$1', + 'params' => [ 'test2' => '$2' ], + 'options' => [ '$2' => 'c' ] + ], + '/$1' + ]; + $tests += [ + // Restricted value parameter tests + 'Restricted 1' => [ + $restrictedPatterns, + "/asdf/Foo", + [ 'title' => "asdf/Foo" ] + ], + 'Restricted 2' => [ + $restrictedPatterns, + "/a/Foo", + [ 'title' => "Foo", 'test' => 'a' ] + ], + 'Restricted 3' => [ + $restrictedPatterns, + "/c/Foo", + [ 'title' => "Foo", 'test2' => 'c' ] + ], + + // Callback test + 'Callback' => [ + [ [ + 'path' => "/$1", + 'params' => [ 'a' => 'b', 'data:foo' => 'bar' ], + 'options' => [ 'callback' => [ __CLASS__, 'callbackForTest' ] ] + ] ], + '/Foo', + [ + 'title' => "Foo", + 'x' => 'Foo', + 'a' => 'b', + 'foo' => 'bar' + ] + ], + + // Test to ensure that matches are not made if a parameter expects nonexistent input + 'Fail' => [ + [ [ + 'path' => "/wiki/$1", + 'params' => [ 'title' => "$1$2" ], + ] ], + "/wiki/A", + [] + ], + + // Make sure the router handles titles like Special:Recentchanges correctly + 'Special title' => [ + "/wiki/$1", + "/wiki/Special:Recentchanges", + [ 'title' => "Special:Recentchanges" ] + ], + + // Make sure the router decodes urlencoding properly + 'URL encoding' => [ + "/wiki/$1", + "/wiki/Title_With%20Space", + [ 'title' => "Title_With Space" ] + ], + + // Double slash and dot expansion + 'Double slash in prefix' => [ + '/wiki/$1', + '//wiki/Foo', + [ 'title' => 'Foo' ] + ], + 'Double slash at start of $1' => [ + '/wiki/$1', + '/wiki//Foo', + [ 'title' => '/Foo' ] + ], + 'Double slash in middle of $1' => [ + '/wiki/$1', + '/wiki/.hack//SIGN', + [ 'title' => '.hack//SIGN' ] + ], + 'Dots removed 1' => [ + '/wiki/$1', + '/x/../wiki/Foo', + [ 'title' => 'Foo' ] + ], + 'Dots removed 2' => [ + '/wiki/$1', + '/./wiki/Foo', + [ 'title' => 'Foo' ] + ], + 'Dots retained 1' => [ + '/wiki/$1', + '/wiki/../wiki/Foo', + [ 'title' => '../wiki/Foo' ] + ], + 'Dots retained 2' => [ + '/wiki/$1', + '/wiki/./Foo', + [ 'title' => './Foo' ] + ], + 'Triple slash' => [ + '/wiki/$1', + '///wiki/Foo', + [ 'title' => 'Foo' ] + ], + // '..' only traverses one slash, see e.g. RFC 3986 + 'Dots traversing double slash 1' => [ + '/wiki/$1', + '/a//b/../../wiki/Foo', + [] + ], + 'Dots traversing double slash 2' => [ + '/wiki/$1', + '/a//b/../../../wiki/Foo', + [ 'title' => 'Foo' ] + ], + ]; + + // Make sure the router doesn't break on special characters like $ used in regexp replacements + foreach ( [ "$", "$1", "\\", "\\$1" ] as $char ) { + $tests["Regexp character $char"] = [ + "/wiki/$1", + "/wiki/$char", + [ 'title' => "$char" ] + ]; + } + + $tests += [ + // Make sure the router handles characters like +&() properly + "Special characters" => [ + "/wiki/$1", + "/wiki/Plus+And&Dollar\\Stuff();[]{}*", + [ 'title' => "Plus+And&Dollar\\Stuff();[]{}*" ], + ], + + // Make sure the router handles unicode characters correctly + "Unicode 1" => [ + "/wiki/$1", + "/wiki/Spécial:Modifications_récentes" , + [ 'title' => "Spécial:Modifications_récentes" ], + ], + + "Unicode 2" => [ + "/wiki/$1", + "/wiki/Sp%C3%A9cial:Modifications_r%C3%A9centes", + [ 'title' => "Spécial:Modifications_récentes" ], + ] + ]; + + // Ensure the router doesn't choke on long paths. + $lorem = "Lorem_ipsum_dolor_sit_amet,_consectetur_adipisicing_elit,_sed_do_eiusmod_" . + "tempor_incididunt_ut_labore_et_dolore_magna_aliqua._Ut_enim_ad_minim_veniam,_quis_" . + "nostrud_exercitation_ullamco_laboris_nisi_ut_aliquip_ex_ea_commodo_consequat._" . + "Duis_aute_irure_dolor_in_reprehenderit_in_voluptate_velit_esse_cillum_dolore_" . + "eu_fugiat_nulla_pariatur._Excepteur_sint_occaecat_cupidatat_non_proident,_sunt_" . + "in_culpa_qui_officia_deserunt_mollit_anim_id_est_laborum."; + + $tests += [ + "Long path" => [ + "/wiki/$1", + "/wiki/$lorem", + [ 'title' => $lorem ] + ], + + // Ensure that the php passed site of parameter values are not urldecoded + "Pattern urlencoding" => [ + [ [ 'path' => "/wiki/$1", 'params' => [ 'title' => '%20:$1' ] ] ], + "/wiki/Foo", + [ 'title' => '%20:Foo' ] + ], + + // Ensure that raw parameter values do not have any variable replacements or urldecoding + "Raw param value" => [ + [ [ 'path' => "/wiki/$1", 'params' => [ 'title' => [ 'value' => 'bar%20$1' ] ] ] ], + "/wiki/Foo", + [ 'title' => 'bar%20$1' ] + ] + ]; + + return $tests; + } + + /** + * Test path parsing + * @dataProvider provideParse + */ + public function testParse( $patterns, $path, $expected ) { + $patterns = (array)$patterns; + + $router = new PathRouter; + foreach ( $patterns as $pattern ) { + if ( is_array( $pattern ) ) { + $router->add( $pattern['path'], $pattern['params'] ?? [], + $pattern['options'] ?? [] ); + } else { + $router->add( $pattern ); + } + } + $matches = $router->parse( $path ); + $this->assertEquals( $matches, $expected ); + } + + public static function callbackForTest( &$matches, $data ) { + $matches['x'] = $data['$1']; + $matches['foo'] = $data['foo']; + } + + public static function provideWeight() { + return [ + [ '/Foo', [ 'title' => 'Foo' ] ], + [ '/Bar', [ 'ping' => 'pong' ] ], + [ '/Baz', [ 'marco' => 'polo' ] ], + [ '/asdf-foo', [ 'title' => 'qwerty-foo' ] ], + [ '/qwerty-bar', [ 'title' => 'asdf-bar' ] ], + [ '/a/Foo', [ 'title' => 'Foo' ] ], + [ '/asdf/Foo', [ 'title' => 'Foo' ] ], + [ '/qwerty/Foo', [ 'title' => 'Foo', 'qwerty' => 'qwerty' ] ], + [ '/baz/Foo', [ 'title' => 'Foo', 'unrestricted' => 'baz' ] ], + [ '/y/Foo', [ 'title' => 'Foo', 'restricted-to-y' => 'y' ] ], + ]; + } + + /** + * Test to ensure weight of paths is handled correctly + * @dataProvider provideWeight + */ + public function testWeight( $path, $expected ) { + $router = new PathRouter; + $router->addStrict( "/Bar", [ 'ping' => 'pong' ] ); + $router->add( "/asdf-$1", [ 'title' => 'qwerty-$1' ] ); + $router->add( "/$1" ); + $router->add( "/qwerty-$1", [ 'title' => 'asdf-$1' ] ); + $router->addStrict( "/Baz", [ 'marco' => 'polo' ] ); + $router->add( "/a/$1" ); + $router->add( "/asdf/$1" ); + $router->add( "/$2/$1", [ 'unrestricted' => '$2' ] ); + $router->add( [ 'qwerty' => "/qwerty/$1" ], [ 'qwerty' => '$key' ] ); + $router->add( "/$2/$1", [ 'restricted-to-y' => '$2' ], [ '$2' => 'y' ] ); + + $this->assertEquals( $router->parse( $path ), $expected ); + } +} diff --git a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php new file mode 100644 index 0000000000..aedf292eca --- /dev/null +++ b/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php @@ -0,0 +1,75 @@ +getMockBuilder( Title::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $title; + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::__construct + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getRole() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getNameMessageKey() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getDefaultModel() + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getOutputLayoutHints() + */ + public function testConstruction() { + $handler = new FallbackSlotRoleHandler( 'foo' ); + $this->assertSame( 'foo', $handler->getRole() ); + $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); + + $title = $this->makeBlankTitleObject(); + $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title ) ); + + $hints = $handler->getOutputLayoutHints(); + $this->assertArrayHasKey( 'display', $hints ); + $this->assertArrayHasKey( 'region', $hints ); + $this->assertArrayHasKey( 'placement', $hints ); + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedModel() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + // For the fallback handler, no models are allowed + $title = $this->makeBlankTitleObject(); + $this->assertFalse( $handler->isAllowedModel( 'FooModel', $title ) ); + $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedOn() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + $title = $this->makeBlankTitleObject(); + $this->assertFalse( $handler->isAllowedOn( $title ) ); + } + + /** + * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::supportsArticleCount() + */ + public function testSupportsArticleCount() { + $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' ); + + $this->assertFalse( $handler->supportsArticleCount() ); + } + +} diff --git a/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php new file mode 100644 index 0000000000..5e32574d40 --- /dev/null +++ b/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php @@ -0,0 +1,79 @@ +getMockBuilder( Title::class ) + ->disableOriginalConstructor() + ->getMock(); + + $title->method( 'getNamespace' ) + ->willReturn( $ns ); + + return $title; + } + + /** + * @covers \MediaWiki\Revision\MainSlotRoleHandler::__construct + * @covers \MediaWiki\Revision\MainSlotRoleHandler::getRole() + * @covers \MediaWiki\Revision\MainSlotRoleHandler::getNameMessageKey() + * @covers \MediaWiki\Revision\MainSlotRoleHandler::getOutputLayoutHints() + */ + public function testConstruction() { + $handler = new MainSlotRoleHandler( [] ); + $this->assertSame( 'main', $handler->getRole() ); + $this->assertSame( 'slot-name-main', $handler->getNameMessageKey() ); + + $hints = $handler->getOutputLayoutHints(); + $this->assertArrayHasKey( 'display', $hints ); + $this->assertArrayHasKey( 'region', $hints ); + $this->assertArrayHasKey( 'placement', $hints ); + } + + /** + * @covers \MediaWiki\Revision\MainSlotRoleHandler::getDefaultModel() + */ + public function testFetDefaultModel() { + $handler = new MainSlotRoleHandler( [ 100 => CONTENT_MODEL_TEXT ] ); + + // For the main handler, the namespace determins the default model + $titleMain = $this->makeTitleObject( NS_MAIN ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $handler->getDefaultModel( $titleMain ) ); + + $title100 = $this->makeTitleObject( 100 ); + $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title100 ) ); + } + + /** + * @covers \MediaWiki\Revision\MainSlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedModel() { + $handler = new MainSlotRoleHandler( [] ); + + // For the main handler, (nearly) all models are allowed + $title = $this->makeTitleObject( NS_MAIN ); + $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_WIKITEXT, $title ) ); + $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_TEXT, $title ) ); + } + + /** + * @covers \MediaWiki\Revision\MainSlotRoleHandler::supportsArticleCount() + */ + public function testSupportsArticleCount() { + $handler = new MainSlotRoleHandler( [] ); + + $this->assertTrue( $handler->supportsArticleCount() ); + } + +} diff --git a/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php new file mode 100644 index 0000000000..138d6bcba1 --- /dev/null +++ b/tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php @@ -0,0 +1,197 @@ +getMockLoadBalancerFactory(), + $this->getMockBlobStoreFactory(), + $this->getNameTableStoreFactory(), + $this->getMockSlotRoleRegistry(), + $this->getHashWANObjectCache(), + $this->getMockCommentStore(), + ActorMigration::newMigration(), + MIGRATION_OLD, + $this->getMockLoggerSpi(), + true + ); + $this->assertTrue( true ); + } + + public function provideWikiIds() { + yield [ true ]; + yield [ false ]; + yield [ 'somewiki' ]; + yield [ 'somewiki', MIGRATION_OLD , false ]; + yield [ 'somewiki', MIGRATION_NEW , true ]; + } + + /** + * @dataProvider provideWikiIds + * @covers \MediaWiki\Revision\RevisionStoreFactory::getRevisionStore + */ + public function testGetRevisionStore( + $wikiId, + $mcrMigrationStage = MIGRATION_OLD, + $contentHandlerUseDb = true + ) { + $lbFactory = $this->getMockLoadBalancerFactory(); + $blobStoreFactory = $this->getMockBlobStoreFactory(); + $nameTableStoreFactory = $this->getNameTableStoreFactory(); + $slotRoleRegistry = $this->getMockSlotRoleRegistry(); + $cache = $this->getHashWANObjectCache(); + $commentStore = $this->getMockCommentStore(); + $actorMigration = ActorMigration::newMigration(); + $loggerProvider = $this->getMockLoggerSpi(); + + $factory = new RevisionStoreFactory( + $lbFactory, + $blobStoreFactory, + $nameTableStoreFactory, + $slotRoleRegistry, + $cache, + $commentStore, + $actorMigration, + $mcrMigrationStage, + $loggerProvider, + $contentHandlerUseDb + ); + + $store = $factory->getRevisionStore( $wikiId ); + $wrapper = TestingAccessWrapper::newFromObject( $store ); + + // ensure the correct object type is returned + $this->assertInstanceOf( RevisionStore::class, $store ); + + // ensure the RevisionStore is for the given wikiId + $this->assertSame( $wikiId, $wrapper->wikiId ); + + // ensure all other required services are correctly set + $this->assertSame( $cache, $wrapper->cache ); + $this->assertSame( $commentStore, $wrapper->commentStore ); + $this->assertSame( $mcrMigrationStage, $wrapper->mcrMigrationStage ); + $this->assertSame( $actorMigration, $wrapper->actorMigration ); + $this->assertSame( $contentHandlerUseDb, $store->getContentHandlerUseDB() ); + + $this->assertInstanceOf( ILoadBalancer::class, $wrapper->loadBalancer ); + $this->assertInstanceOf( BlobStore::class, $wrapper->blobStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->contentModelStore ); + $this->assertInstanceOf( NameTableStore::class, $wrapper->slotRoleStore ); + $this->assertInstanceOf( LoggerInterface::class, $wrapper->logger ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILoadBalancer + */ + private function getMockLoadBalancer() { + return $this->getMockBuilder( ILoadBalancer::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|ILBFactory + */ + private function getMockLoadBalancerFactory() { + $mock = $this->getMockBuilder( ILBFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'getMainLB' ) + ->willReturnCallback( function () { + return $this->getMockLoadBalancer(); + } ); + + return $mock; + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore + */ + private function getMockSqlBlobStore() { + return $this->getMockBuilder( SqlBlobStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|BlobStoreFactory + */ + private function getMockBlobStoreFactory() { + $mock = $this->getMockBuilder( BlobStoreFactory::class ) + ->disableOriginalConstructor()->getMock(); + + $mock->method( 'newSqlBlobStore' ) + ->willReturnCallback( function () { + return $this->getMockSqlBlobStore(); + } ); + + return $mock; + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|SlotRoleRegistry + */ + private function getMockSlotRoleRegistry() { + $mock = $this->getMockBuilder( SlotRoleRegistry::class ) + ->disableOriginalConstructor()->getMock(); + + return $mock; + } + + /** + * @return NameTableStoreFactory + */ + private function getNameTableStoreFactory() { + return new NameTableStoreFactory( + $this->getMockLoadBalancerFactory(), + $this->getHashWANObjectCache(), + new NullLogger() ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore + */ + private function getMockCommentStore() { + return $this->getMockBuilder( CommentStore::class ) + ->disableOriginalConstructor()->getMock(); + } + + private function getHashWANObjectCache() { + return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] ); + } + + /** + * @return \PHPUnit_Framework_MockObject_MockObject|LoggerSpi + */ + private function getMockLoggerSpi() { + $mock = $this->getMock( LoggerSpi::class ); + + $mock->method( 'getLogger' ) + ->willReturn( new NullLogger() ); + + return $mock; + } + +} diff --git a/tests/phpunit/includes/Revision/SlotRecordTest.php b/tests/phpunit/includes/Revision/SlotRecordTest.php new file mode 100644 index 0000000000..1b6ff2aace --- /dev/null +++ b/tests/phpunit/includes/Revision/SlotRecordTest.php @@ -0,0 +1,408 @@ + 1234, + 'slot_content_id' => 33, + 'content_size' => '5', + 'content_sha1' => 'someHash', + 'content_address' => 'tt:456', + 'model_name' => CONTENT_MODEL_WIKITEXT, + 'format_name' => CONTENT_FORMAT_WIKITEXT, + 'slot_revision_id' => '2', + 'slot_origin' => '1', + 'role_name' => 'myRole', + ]; + return (object)$data; + } + + public function testCompleteConstruction() { + $row = $this->makeRow(); + $record = new SlotRecord( $row, new WikitextContent( 'A' ) ); + + $this->assertTrue( $record->hasAddress() ); + $this->assertTrue( $record->hasContentId() ); + $this->assertTrue( $record->hasRevision() ); + $this->assertTrue( $record->isInherited() ); + $this->assertSame( 'A', $record->getContent()->getText() ); + $this->assertSame( 5, $record->getSize() ); + $this->assertSame( 'someHash', $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 1, $record->getOrigin() ); + $this->assertSame( 'tt:456', $record->getAddress() ); + $this->assertSame( 33, $record->getContentId() ); + $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function testConstructionDeferred() { + $row = $this->makeRow( [ + 'content_size' => null, // to be computed + 'content_sha1' => null, // to be computed + 'format_name' => function () { + return CONTENT_FORMAT_WIKITEXT; + }, + 'slot_revision_id' => '2', + 'slot_origin' => '2', + 'slot_content_id' => function () { + return null; + }, + ] ); + + $content = function () { + return new WikitextContent( 'A' ); + }; + + $record = new SlotRecord( $row, $content ); + + $this->assertTrue( $record->hasAddress() ); + $this->assertTrue( $record->hasRevision() ); + $this->assertFalse( $record->hasContentId() ); + $this->assertFalse( $record->isInherited() ); + $this->assertSame( 'A', $record->getContent()->getText() ); + $this->assertSame( 1, $record->getSize() ); + $this->assertNotNull( $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 2, $record->getRevision() ); + $this->assertSame( 'tt:456', $record->getAddress() ); + $this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function testNewUnsaved() { + $record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) ); + + $this->assertFalse( $record->hasAddress() ); + $this->assertFalse( $record->hasContentId() ); + $this->assertFalse( $record->hasRevision() ); + $this->assertFalse( $record->isInherited() ); + $this->assertFalse( $record->hasOrigin() ); + $this->assertSame( 'A', $record->getContent()->getText() ); + $this->assertSame( 1, $record->getSize() ); + $this->assertNotNull( $record->getSha1() ); + $this->assertSame( CONTENT_MODEL_WIKITEXT, $record->getModel() ); + $this->assertSame( 'myRole', $record->getRole() ); + } + + public function provideInvalidConstruction() { + yield 'both null' => [ null, null ]; + yield 'null row' => [ null, new WikitextContent( 'A' ) ]; + yield 'array row' => [ [], new WikitextContent( 'A' ) ]; + yield 'empty row' => [ (object)[], new WikitextContent( 'A' ) ]; + yield 'null content' => [ (object)[], null ]; + } + + /** + * @dataProvider provideInvalidConstruction + */ + public function testInvalidConstruction( $row, $content ) { + $this->setExpectedException( InvalidArgumentException::class ); + new SlotRecord( $row, $content ); + } + + public function testGetContentId_fails() { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getContentId(); + } + + public function testGetAddress_fails() { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getAddress(); + } + + public function provideIncomplete() { + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + yield 'unsaved' => [ $unsaved ]; + + $parent = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); + $inherited = SlotRecord::newInherited( $parent ); + yield 'inherited' => [ $inherited ]; + } + + /** + * @dataProvider provideIncomplete + */ + public function testGetRevision_fails( SlotRecord $record ) { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getRevision(); + } + + /** + * @dataProvider provideIncomplete + */ + public function testGetOrigin_fails( SlotRecord $record ) { + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + $this->setExpectedException( IncompleteRevisionException::class ); + + $record->getOrigin(); + } + + public function provideHashStability() { + yield [ '', 'phoiac9h4m842xq45sp7s6u21eteeq1' ]; + yield [ 'Lorem ipsum', 'hcr5u40uxr81d3nx89nvwzclfz6r9c5' ]; + } + + /** + * @dataProvider provideHashStability + */ + public function testHashStability( $text, $hash ) { + // Changing the output of the hash function will break things horribly! + + $this->assertSame( $hash, SlotRecord::base36Sha1( $text ) ); + + $record = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $text ) ); + $this->assertSame( $hash, $record->getSha1() ); + } + + public function testNewWithSuppressedContent() { + $input = new SlotRecord( $this->makeRow(), new WikitextContent( 'A' ) ); + $output = SlotRecord::newWithSuppressedContent( $input ); + + $this->setExpectedException( SuppressedDataException::class ); + $output->getContent(); + } + + public function testNewInherited() { + $row = $this->makeRow( [ 'slot_revision_id' => 7, 'slot_origin' => 7 ] ); + $parent = new SlotRecord( $row, new WikitextContent( 'A' ) ); + + // This would happen while doing an edit, before saving revision meta-data. + $inherited = SlotRecord::newInherited( $parent ); + + $this->assertSame( $parent->getContentId(), $inherited->getContentId() ); + $this->assertSame( $parent->getAddress(), $inherited->getAddress() ); + $this->assertSame( $parent->getContent(), $inherited->getContent() ); + $this->assertTrue( $inherited->isInherited() ); + $this->assertTrue( $inherited->hasOrigin() ); + $this->assertFalse( $inherited->hasRevision() ); + + // make sure we didn't mess with the internal state of $parent + $this->assertFalse( $parent->isInherited() ); + $this->assertSame( 7, $parent->getRevision() ); + + // This would happen while doing an edit, after saving the revision meta-data + // and content meta-data. + $saved = SlotRecord::newSaved( + 10, + $inherited->getContentId(), + $inherited->getAddress(), + $inherited + ); + $this->assertSame( $parent->getContentId(), $saved->getContentId() ); + $this->assertSame( $parent->getAddress(), $saved->getAddress() ); + $this->assertSame( $parent->getContent(), $saved->getContent() ); + $this->assertTrue( $saved->isInherited() ); + $this->assertTrue( $saved->hasRevision() ); + $this->assertSame( 10, $saved->getRevision() ); + + // make sure we didn't mess with the internal state of $parent or $inherited + $this->assertSame( 7, $parent->getRevision() ); + $this->assertFalse( $inherited->hasRevision() ); + } + + public function testNewSaved() { + // This would happen while doing an edit, before saving revision meta-data. + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + + // This would happen while doing an edit, after saving the revision meta-data + // and content meta-data. + $saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved ); + $this->assertFalse( $saved->isInherited() ); + $this->assertTrue( $saved->hasOrigin() ); + $this->assertTrue( $saved->hasRevision() ); + $this->assertTrue( $saved->hasAddress() ); + $this->assertTrue( $saved->hasContentId() ); + $this->assertSame( 'theNewAddress', $saved->getAddress() ); + $this->assertSame( 20, $saved->getContentId() ); + $this->assertSame( 'A', $saved->getContent()->getText() ); + $this->assertSame( 10, $saved->getRevision() ); + $this->assertSame( 10, $saved->getOrigin() ); + + // make sure we didn't mess with the internal state of $unsaved + $this->assertFalse( $unsaved->hasAddress() ); + $this->assertFalse( $unsaved->hasContentId() ); + $this->assertFalse( $unsaved->hasRevision() ); + } + + public function provideNewSaved_LogicException() { + $freshRow = $this->makeRow( [ + 'content_id' => 10, + 'content_address' => 'address:1', + 'slot_origin' => 1, + 'slot_revision_id' => 1, + ] ); + + $freshSlot = new SlotRecord( $freshRow, new WikitextContent( 'A' ) ); + yield 'mismatching address' => [ 1, 10, 'address:BAD', $freshSlot ]; + yield 'mismatching revision' => [ 5, 10, 'address:1', $freshSlot ]; + yield 'mismatching content ID' => [ 1, 17, 'address:1', $freshSlot ]; + + $inheritedRow = $this->makeRow( [ + 'content_id' => null, + 'content_address' => null, + 'slot_origin' => 0, + 'slot_revision_id' => 1, + ] ); + + $inheritedSlot = new SlotRecord( $inheritedRow, new WikitextContent( 'A' ) ); + yield 'inherited, but no address' => [ 1, 10, 'address:2', $inheritedSlot ]; + } + + /** + * @dataProvider provideNewSaved_LogicException + */ + public function testNewSaved_LogicException( + $revisionId, + $contentId, + $contentAddress, + SlotRecord $protoSlot + ) { + $this->setExpectedException( LogicException::class ); + SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); + } + + public function provideNewSaved_InvalidArgumentException() { + $unsaved = SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( 'A' ) ); + + yield 'bad revision id' => [ 'xyzzy', 5, 'address', $unsaved ]; + yield 'bad content id' => [ 7, 'xyzzy', 'address', $unsaved ]; + yield 'bad content address' => [ 7, 5, 77, $unsaved ]; + } + + /** + * @dataProvider provideNewSaved_InvalidArgumentException + */ + public function testNewSaved_InvalidArgumentException( + $revisionId, + $contentId, + $contentAddress, + SlotRecord $protoSlot + ) { + $this->setExpectedException( InvalidArgumentException::class ); + SlotRecord::newSaved( $revisionId, $contentId, $contentAddress, $protoSlot ); + } + + public function provideHasSameContent() { + $fail = function () { + self::fail( 'There should be no need to actually load the content.' ); + }; + + $a100a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a100a1b = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a100null = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => null, + ] + ), + $fail + ); + $a100a2 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a2', + ] + ), + $fail + ); + $b100a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'B', + 'content_size' => 100, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a1', + ] + ), + $fail + ); + $a200a1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 200, + 'content_sha1' => 'hash-a', + 'content_address' => 'xxx:a2', + ] + ), + $fail + ); + $a100x1 = new SlotRecord( + $this->makeRow( + [ + 'model_name' => 'A', + 'content_size' => 100, + 'content_sha1' => 'hash-x', + 'content_address' => 'xxx:x1', + ] + ), + $fail + ); + + yield 'same instance' => [ $a100a1, $a100a1, true ]; + yield 'no address' => [ $a100a1, $a100null, true ]; + yield 'same address' => [ $a100a1, $a100a1b, true ]; + yield 'different address' => [ $a100a1, $a100a2, true ]; + yield 'different model' => [ $a100a1, $b100a1, false ]; + yield 'different size' => [ $a100a1, $a200a1, false ]; + yield 'different hash' => [ $a100a1, $a100x1, false ]; + } + + /** + * @dataProvider provideHasSameContent + */ + public function testHasSameContent( SlotRecord $a, SlotRecord $b, $sameContent ) { + $this->assertSame( $sameContent, $a->hasSameContent( $b ) ); + $this->assertSame( $sameContent, $b->hasSameContent( $a ) ); + } + +} diff --git a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php new file mode 100644 index 0000000000..67e9464f33 --- /dev/null +++ b/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php @@ -0,0 +1,67 @@ +getMockBuilder( Title::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $title; + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::__construct + * @covers \MediaWiki\Revision\SlotRoleHandler::getRole() + * @covers \MediaWiki\Revision\SlotRoleHandler::getNameMessageKey() + * @covers \MediaWiki\Revision\SlotRoleHandler::getDefaultModel() + * @covers \MediaWiki\Revision\SlotRoleHandler::getOutputLayoutHints() + */ + public function testConstruction() { + $handler = new SlotRoleHandler( 'foo', 'FooModel', [ 'frob' => 'niz' ] ); + $this->assertSame( 'foo', $handler->getRole() ); + $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() ); + + $title = $this->makeBlankTitleObject(); + $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) ); + + $hints = $handler->getOutputLayoutHints(); + $this->assertArrayHasKey( 'frob', $hints ); + $this->assertSame( 'niz', $hints['frob'] ); + + $this->assertArrayHasKey( 'display', $hints ); + $this->assertArrayHasKey( 'region', $hints ); + $this->assertArrayHasKey( 'placement', $hints ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel() + */ + public function testIsAllowedModel() { + $handler = new SlotRoleHandler( 'foo', 'FooModel' ); + + $title = $this->makeBlankTitleObject(); + $this->assertTrue( $handler->isAllowedModel( 'FooModel', $title ) ); + $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) ); + } + + /** + * @covers \MediaWiki\Revision\SlotRoleHandler::supportsArticleCount() + */ + public function testSupportsArticleCount() { + $handler = new SlotRoleHandler( 'foo', 'FooModel' ); + + $this->assertFalse( $handler->supportsArticleCount() ); + } + +} diff --git a/tests/phpunit/includes/SanitizerValidateEmailTest.php b/tests/phpunit/includes/SanitizerValidateEmailTest.php new file mode 100644 index 0000000000..c4e430848b --- /dev/null +++ b/tests/phpunit/includes/SanitizerValidateEmailTest.php @@ -0,0 +1,105 @@ +assertEquals( + $expected, + Sanitizer::validateEmail( $addr ), + $msg + ); + } + + private function valid( $addr, $msg = '' ) { + $this->checkEmail( $addr, true, $msg ); + } + + private function invalid( $addr, $msg = '' ) { + $this->checkEmail( $addr, false, $msg ); + } + + public function testEmailWellKnownUserAtHostDotTldAreValid() { + $this->valid( 'user@example.com' ); + $this->valid( 'user@example.museum' ); + } + + public function testEmailWithUpperCaseCharactersAreValid() { + $this->valid( 'USER@example.com' ); + $this->valid( 'user@EXAMPLE.COM' ); + $this->valid( 'user@Example.com' ); + $this->valid( 'USER@eXAMPLE.com' ); + } + + public function testEmailWithAPlusInUserName() { + $this->valid( 'user+sub@example.com' ); + $this->valid( 'user+@example.com' ); + } + + public function testEmailDoesNotNeedATopLevelDomain() { + $this->valid( "user@localhost" ); + $this->valid( "FooBar@localdomain" ); + $this->valid( "nobody@mycompany" ); + } + + public function testEmailWithWhiteSpacesBeforeOrAfterAreInvalids() { + $this->invalid( " user@host.com" ); + $this->invalid( "user@host.com " ); + $this->invalid( "\tuser@host.com" ); + $this->invalid( "user@host.com\t" ); + } + + public function testEmailWithWhiteSpacesAreInvalids() { + $this->invalid( "User user@host" ); + $this->invalid( "first last@mycompany" ); + $this->invalid( "firstlast@my company" ); + } + + /** + * T28948 : comma were matched by an incorrect regexp range + */ + public function testEmailWithCommasAreInvalids() { + $this->invalid( "user,foo@example.org" ); + $this->invalid( "userfoo@ex,ample.org" ); + } + + public function testEmailWithHyphens() { + $this->valid( "user-foo@example.org" ); + $this->valid( "userfoo@ex-ample.org" ); + } + + public function testEmailDomainCanNotBeginWithDot() { + $this->invalid( "user@." ); + $this->invalid( "user@.localdomain" ); + $this->invalid( "user@localdomain." ); + $this->valid( "user.@localdomain" ); + $this->valid( ".@localdomain" ); + $this->invalid( ".@a............" ); + } + + public function testEmailWithFunnyCharacters() { + $this->valid( "\$user!ex{this}@123.com" ); + } + + public function testEmailTopLevelDomainCanBeNumerical() { + $this->valid( "user@example.1234" ); + } + + public function testEmailWithoutAtSignIsInvalid() { + $this->invalid( 'useràexample.com' ); + } + + public function testEmailWithOneCharacterDomainIsValid() { + $this->valid( 'user@a' ); + } +} diff --git a/tests/phpunit/includes/ServiceWiringTest.php b/tests/phpunit/includes/ServiceWiringTest.php new file mode 100644 index 0000000000..02e06f8dda --- /dev/null +++ b/tests/phpunit/includes/ServiceWiringTest.php @@ -0,0 +1,16 @@ +assertSame( $sortedServices, $services, + 'Please keep services sorted alphabetically' ); + } +} diff --git a/tests/phpunit/includes/SiteConfigurationTest.php b/tests/phpunit/includes/SiteConfigurationTest.php new file mode 100644 index 0000000000..3b7226245f --- /dev/null +++ b/tests/phpunit/includes/SiteConfigurationTest.php @@ -0,0 +1,379 @@ +mConf = new SiteConfiguration; + + $this->mConf->suffixes = [ 'wikipedia' => 'wiki' ]; + $this->mConf->wikis = [ 'enwiki', 'dewiki', 'frwiki' ]; + $this->mConf->settings = [ + 'SimpleKey' => [ + 'wiki' => 'wiki', + 'tag' => 'tag', + 'enwiki' => 'enwiki', + 'dewiki' => 'dewiki', + 'frwiki' => 'frwiki', + ], + + 'Fallback' => [ + 'default' => 'default', + 'wiki' => 'wiki', + 'tag' => 'tag', + 'frwiki' => 'frwiki', + 'null_wiki' => null, + ], + + 'WithParams' => [ + 'default' => '$lang $site $wiki', + ], + + '+SomeGlobal' => [ + 'wiki' => [ + 'wiki' => 'wiki', + ], + 'tag' => [ + 'tag' => 'tag', + ], + 'enwiki' => [ + 'enwiki' => 'enwiki', + ], + 'dewiki' => [ + 'dewiki' => 'dewiki', + ], + 'frwiki' => [ + 'frwiki' => 'frwiki', + ], + ], + + 'MergeIt' => [ + '+wiki' => [ + 'wiki' => 'wiki', + ], + '+tag' => [ + 'tag' => 'tag', + ], + 'default' => [ + 'default' => 'default', + ], + '+enwiki' => [ + 'enwiki' => 'enwiki', + ], + '+dewiki' => [ + 'dewiki' => 'dewiki', + ], + '+frwiki' => [ + 'frwiki' => 'frwiki', + ], + ], + ]; + + $GLOBALS['SomeGlobal'] = [ 'SomeGlobal' => 'SomeGlobal' ]; + } + + /** + * This function is used as a callback within the tests below + */ + public static function getSiteParamsCallback( $conf, $wiki ) { + $site = null; + $lang = null; + foreach ( $conf->suffixes as $suffix ) { + if ( substr( $wiki, -strlen( $suffix ) ) == $suffix ) { + $site = $suffix; + $lang = substr( $wiki, 0, -strlen( $suffix ) ); + break; + } + } + + return [ + 'suffix' => $site, + 'lang' => $lang, + 'params' => [ + 'lang' => $lang, + 'site' => $site, + 'wiki' => $wiki, + ], + 'tags' => [ 'tag' ], + ]; + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDb() { + $this->assertEquals( + [ 'wikipedia', 'en' ], + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB()' + ); + $this->assertEquals( + [ 'wikipedia', '' ], + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() on a suffix' + ); + $this->assertEquals( + [ null, null ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki' + ); + + $this->mConf->suffixes = [ 'wiki', '' ]; + $this->assertEquals( + [ '', 'wikien' ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() on a non-existing wiki (2)' + ); + } + + /** + * @covers SiteConfiguration::getLocalDatabases + */ + public function testGetLocalDatabases() { + $this->assertEquals( + [ 'enwiki', 'dewiki', 'frwiki' ], + $this->mConf->getLocalDatabases(), + 'getLocalDatabases()' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testGetConfVariables() { + // Simple + $this->assertEquals( + 'enwiki', + $this->mConf->get( 'SimpleKey', 'enwiki', 'wiki' ), + 'get(): simple setting on an existing wiki' + ); + $this->assertEquals( + 'dewiki', + $this->mConf->get( 'SimpleKey', 'dewiki', 'wiki' ), + 'get(): simple setting on an existing wiki (2)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'SimpleKey', 'frwiki', 'wiki' ), + 'get(): simple setting on an existing wiki (3)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'SimpleKey', 'wiki', 'wiki' ), + 'get(): simple setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'SimpleKey', 'eswiki', 'wiki' ), + 'get(): simple setting on an non-existing wiki' + ); + + // Fallback + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'enwiki', 'wiki' ), + 'get(): fallback setting on an existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'Fallback', 'dewiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an existing wiki (with wiki tag)' + ); + $this->assertEquals( + 'frwiki', + $this->mConf->get( 'Fallback', 'frwiki', 'wiki', [], [ 'tag' ] ), + 'get(): no fallback if wiki has its own setting (matching tag)' + ); + $this->assertSame( + // Potential regression test for T192855 + null, + $this->mConf->get( 'Fallback', 'null_wiki', 'wiki', [], [ 'tag' ] ), + 'get(): no fallback if wiki has its own setting (matching tag and uses null)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'wiki', 'wiki' ), + 'get(): fallback setting on an suffix' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'wiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an suffix (with wiki tag)' + ); + $this->assertEquals( + 'wiki', + $this->mConf->get( 'Fallback', 'eswiki', 'wiki' ), + 'get(): fallback setting on an non-existing wiki' + ); + $this->assertEquals( + 'tag', + $this->mConf->get( 'Fallback', 'eswiki', 'wiki', [], [ 'tag' ] ), + 'get(): fallback setting on an non-existing wiki (with wiki tag)' + ); + + // Merging + $common = [ 'wiki' => 'wiki', 'default' => 'default' ]; + $commonTag = [ 'tag' => 'tag', 'wiki' => 'wiki', 'default' => 'default' ]; + $this->assertEquals( + [ 'enwiki' => 'enwiki' ] + $common, + $this->mConf->get( 'MergeIt', 'enwiki', 'wiki' ), + 'get(): merging setting on an existing wiki' + ); + $this->assertEquals( + [ 'enwiki' => 'enwiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'enwiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (with tag)' + ); + $this->assertEquals( + [ 'dewiki' => 'dewiki' ] + $common, + $this->mConf->get( 'MergeIt', 'dewiki', 'wiki' ), + 'get(): merging setting on an existing wiki (2)' + ); + $this->assertEquals( + [ 'dewiki' => 'dewiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'dewiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (2) (with tag)' + ); + $this->assertEquals( + [ 'frwiki' => 'frwiki' ] + $common, + $this->mConf->get( 'MergeIt', 'frwiki', 'wiki' ), + 'get(): merging setting on an existing wiki (3)' + ); + $this->assertEquals( + [ 'frwiki' => 'frwiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'frwiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an existing wiki (3) (with tag)' + ); + $this->assertEquals( + [ 'wiki' => 'wiki' ] + $common, + $this->mConf->get( 'MergeIt', 'wiki', 'wiki' ), + 'get(): merging setting on an suffix' + ); + $this->assertEquals( + [ 'wiki' => 'wiki' ] + $commonTag, + $this->mConf->get( 'MergeIt', 'wiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an suffix (with tag)' + ); + $this->assertEquals( + $common, + $this->mConf->get( 'MergeIt', 'eswiki', 'wiki' ), + 'get(): merging setting on an non-existing wiki' + ); + $this->assertEquals( + $commonTag, + $this->mConf->get( 'MergeIt', 'eswiki', 'wiki', [], [ 'tag' ] ), + 'get(): merging setting on an non-existing wiki (with tag)' + ); + } + + /** + * @covers SiteConfiguration::siteFromDB + */ + public function testSiteFromDbWithCallback() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + [ 'wiki', 'en' ], + $this->mConf->siteFromDB( 'enwiki' ), + 'siteFromDB() with callback' + ); + $this->assertEquals( + [ 'wiki', '' ], + $this->mConf->siteFromDB( 'wiki' ), + 'siteFromDB() with callback on a suffix' + ); + $this->assertEquals( + [ null, null ], + $this->mConf->siteFromDB( 'wikien' ), + 'siteFromDB() with callback on a non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::get + */ + public function testParameterReplacement() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $this->assertEquals( + 'en wiki enwiki', + $this->mConf->get( 'WithParams', 'enwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki' + ); + $this->assertEquals( + 'de wiki dewiki', + $this->mConf->get( 'WithParams', 'dewiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (2)' + ); + $this->assertEquals( + 'fr wiki frwiki', + $this->mConf->get( 'WithParams', 'frwiki', 'wiki' ), + 'get(): parameter replacement on an existing wiki (3)' + ); + $this->assertEquals( + ' wiki wiki', + $this->mConf->get( 'WithParams', 'wiki', 'wiki' ), + 'get(): parameter replacement on an suffix' + ); + $this->assertEquals( + 'es wiki eswiki', + $this->mConf->get( 'WithParams', 'eswiki', 'wiki' ), + 'get(): parameter replacement on an non-existing wiki' + ); + } + + /** + * @covers SiteConfiguration::getAll + */ + public function testGetAllGlobals() { + $this->mConf->siteParamsCallback = 'SiteConfigurationTest::getSiteParamsCallback'; + + $getall = [ + 'SimpleKey' => 'enwiki', + 'Fallback' => 'tag', + 'WithParams' => 'en wiki enwiki', + 'SomeGlobal' => [ 'enwiki' => 'enwiki' ] + $GLOBALS['SomeGlobal'], + 'MergeIt' => [ + 'enwiki' => 'enwiki', + 'tag' => 'tag', + 'wiki' => 'wiki', + 'default' => 'default' + ], + ]; + $this->assertEquals( $getall, $this->mConf->getAll( 'enwiki' ), 'getAll()' ); + + $this->mConf->extractAllGlobals( 'enwiki', 'wiki' ); + + $this->assertEquals( + $getall['SimpleKey'], + $GLOBALS['SimpleKey'], + 'extractAllGlobals(): simple setting' + ); + $this->assertEquals( + $getall['Fallback'], + $GLOBALS['Fallback'], + 'extractAllGlobals(): fallback setting' + ); + $this->assertEquals( + $getall['WithParams'], + $GLOBALS['WithParams'], + 'extractAllGlobals(): parameter replacement' + ); + $this->assertEquals( + $getall['SomeGlobal'], + $GLOBALS['SomeGlobal'], + 'extractAllGlobals(): merging with global' + ); + $this->assertEquals( + $getall['MergeIt'], + $GLOBALS['MergeIt'], + 'extractAllGlobals(): merging setting' + ); + } +} diff --git a/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php new file mode 100644 index 0000000000..252c657867 --- /dev/null +++ b/tests/phpunit/includes/Storage/BlobStoreFactoryTest.php @@ -0,0 +1,46 @@ +getBlobStoreFactory(); + $store = $factory->newBlobStore( $wikiId ); + $this->assertInstanceOf( BlobStore::class, $store ); + + // This only works as we currently know this is a SqlBlobStore object + $wrapper = TestingAccessWrapper::newFromObject( $store ); + $this->assertEquals( $wikiId, $wrapper->wikiId ); + } + + /** + * @dataProvider provideWikiIds + */ + public function testNewSqlBlobStore( $wikiId ) { + $factory = MediaWikiServices::getInstance()->getBlobStoreFactory(); + $store = $factory->newSqlBlobStore( $wikiId ); + $this->assertInstanceOf( SqlBlobStore::class, $store ); + + $wrapper = TestingAccessWrapper::newFromObject( $store ); + $this->assertEquals( $wikiId, $wrapper->wikiId ); + } + +} diff --git a/tests/phpunit/includes/Storage/PreparedEditTest.php b/tests/phpunit/includes/Storage/PreparedEditTest.php new file mode 100644 index 0000000000..29999ee535 --- /dev/null +++ b/tests/phpunit/includes/Storage/PreparedEditTest.php @@ -0,0 +1,22 @@ +parserOutputCallback = function () { + return new ParserOutput(); + }; + + $this->assertEquals( $output, $edit->getOutput() ); + $this->assertEquals( $output, $edit->output ); + } +} diff --git a/tests/phpunit/includes/TitleArrayFromResultTest.php b/tests/phpunit/includes/TitleArrayFromResultTest.php new file mode 100644 index 0000000000..32c757101a --- /dev/null +++ b/tests/phpunit/includes/TitleArrayFromResultTest.php @@ -0,0 +1,117 @@ +getMockBuilder( Wikimedia\Rdbms\ResultWrapper::class ) + ->disableOriginalConstructor(); + + $resultWrapper = $resultWrapper->getMock(); + $resultWrapper->expects( $this->atLeastOnce() ) + ->method( 'current' ) + ->will( $this->returnValue( $row ) ); + $resultWrapper->expects( $this->any() ) + ->method( 'numRows' ) + ->will( $this->returnValue( $numRows ) ); + + return $resultWrapper; + } + + private function getRowWithTitle( $namespace = 3, $title = 'foo' ) { + $row = new stdClass(); + $row->page_namespace = $namespace; + $row->page_title = $title; + return $row; + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithFalseRow() { + $row = false; + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = new TitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertEquals( $row, $object->current ); + } + + /** + * @covers TitleArrayFromResult::__construct + */ + public function testConstructionWithRow() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $resultWrapper = $this->getMockResultWrapper( $row ); + + $object = new TitleArrayFromResult( $resultWrapper ); + + $this->assertEquals( $resultWrapper, $object->res ); + $this->assertSame( 0, $object->key ); + $this->assertInstanceOf( Title::class, $object->current ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public static function provideNumberOfRows() { + return [ + [ 0 ], + [ 1 ], + [ 122 ], + ]; + } + + /** + * @dataProvider provideNumberOfRows + * @covers TitleArrayFromResult::count + */ + public function testCountWithVaryingValues( $numRows ) { + $object = new TitleArrayFromResult( $this->getMockResultWrapper( + $this->getRowWithTitle(), + $numRows + ) ); + $this->assertEquals( $numRows, $object->count() ); + } + + /** + * @covers TitleArrayFromResult::current + */ + public function testCurrentAfterConstruction() { + $namespace = 0; + $title = 'foo'; + $row = $this->getRowWithTitle( $namespace, $title ); + $object = new TitleArrayFromResult( $this->getMockResultWrapper( $row ) ); + $this->assertInstanceOf( Title::class, $object->current() ); + $this->assertEquals( $namespace, $object->current->mNamespace ); + $this->assertEquals( $title, $object->current->mTextform ); + } + + public function provideTestValid() { + return [ + [ $this->getRowWithTitle(), true ], + [ false, false ], + ]; + } + + /** + * @dataProvider provideTestValid + * @covers TitleArrayFromResult::valid + */ + public function testValid( $input, $expected ) { + $object = new TitleArrayFromResult( $this->getMockResultWrapper( $input ) ); + $this->assertEquals( $expected, $object->valid() ); + } + + // @todo unit test for key() + // @todo unit test for next() + // @todo unit test for rewind() +} diff --git a/tests/phpunit/includes/WikiReferenceTest.php b/tests/phpunit/includes/WikiReferenceTest.php new file mode 100644 index 0000000000..e4b21ce5ac --- /dev/null +++ b/tests/phpunit/includes/WikiReferenceTest.php @@ -0,0 +1,166 @@ + [ 'foo.bar', 'http://foo.bar' ], + 'https' => [ 'foo.bar', 'http://foo.bar' ], + + // apparently, this is the expected behavior + 'invalid' => [ 'purple kittens', 'purple kittens' ], + ]; + } + + /** + * @dataProvider provideGetDisplayName + */ + public function testGetDisplayName( $expected, $canonicalServer ) { + $reference = new WikiReference( $canonicalServer, '/wiki/$1' ); + $this->assertEquals( $expected, $reference->getDisplayName() ); + } + + public function testGetCanonicalServer() { + $reference = new WikiReference( 'https://acme.com', '/wiki/$1', '//acme.com' ); + $this->assertEquals( 'https://acme.com', $reference->getCanonicalServer() ); + } + + public function provideGetCanonicalUrl() { + return [ + 'no fragment' => [ + 'https://acme.com/wiki/Foo', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + null + ], + 'empty fragment' => [ + 'https://acme.com/wiki/Foo', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + '' + ], + 'fragment' => [ + 'https://acme.com/wiki/Foo#Bar', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + 'Bar' + ], + 'double fragment' => [ + 'https://acme.com/wiki/Foo#Bar%23Xus', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + 'Bar#Xus' + ], + 'escaped fragment' => [ + 'https://acme.com/wiki/Foo%23Bar', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo#Bar', + null + ], + 'empty path' => [ + 'https://acme.com/Foo', + 'https://acme.com', + '//acme.com', + '/$1', + 'Foo', + null + ], + ]; + } + + /** + * @dataProvider provideGetCanonicalUrl + */ + public function testGetCanonicalUrl( + $expected, $canonicalServer, $server, $path, $page, $fragmentId + ) { + $reference = new WikiReference( $canonicalServer, $path, $server ); + $this->assertEquals( $expected, $reference->getCanonicalUrl( $page, $fragmentId ) ); + } + + /** + * @dataProvider provideGetCanonicalUrl + * @note getUrl is an alias for getCanonicalUrl + */ + public function testGetUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) { + $reference = new WikiReference( $canonicalServer, $path, $server ); + $this->assertEquals( $expected, $reference->getUrl( $page, $fragmentId ) ); + } + + public function provideGetFullUrl() { + return [ + 'no fragment' => [ + '//acme.com/wiki/Foo', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + null + ], + 'empty fragment' => [ + '//acme.com/wiki/Foo', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + '' + ], + 'fragment' => [ + '//acme.com/wiki/Foo#Bar', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + 'Bar' + ], + 'double fragment' => [ + '//acme.com/wiki/Foo#Bar%23Xus', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo', + 'Bar#Xus' + ], + 'escaped fragment' => [ + '//acme.com/wiki/Foo%23Bar', + 'https://acme.com', + '//acme.com', + '/wiki/$1', + 'Foo#Bar', + null + ], + 'empty path' => [ + '//acme.com/Foo', + 'https://acme.com', + '//acme.com', + '/$1', + 'Foo', + null + ], + ]; + } + + /** + * @dataProvider provideGetFullUrl + */ + public function testGetFullUrl( $expected, $canonicalServer, $server, $path, $page, $fragmentId ) { + $reference = new WikiReference( $canonicalServer, $path, $server ); + $this->assertEquals( $expected, $reference->getFullUrl( $page, $fragmentId ) ); + } + +} diff --git a/tests/phpunit/includes/XmlJsTest.php b/tests/phpunit/includes/XmlJsTest.php new file mode 100644 index 0000000000..c7975efabc --- /dev/null +++ b/tests/phpunit/includes/XmlJsTest.php @@ -0,0 +1,26 @@ +assertEquals( $value, $obj->value ); + } + + public static function provideConstruction() { + return [ + [ null ], + [ '' ], + ]; + } + +} diff --git a/tests/phpunit/includes/XmlSelectTest.php b/tests/phpunit/includes/XmlSelectTest.php new file mode 100644 index 0000000000..52e20bdb99 --- /dev/null +++ b/tests/phpunit/includes/XmlSelectTest.php @@ -0,0 +1,182 @@ +select = new XmlSelect(); + } + + protected function tearDown() { + parent::tearDown(); + $this->select = null; + } + + /** + * @covers XmlSelect::__construct + */ + public function testConstructWithoutParameters() { + $this->assertEquals( '', $this->select->getHTML() ); + } + + /** + * Parameters are $name (false), $id (false), $default (false) + * @dataProvider provideConstructionParameters + * @covers XmlSelect::__construct + */ + public function testConstructParameters( $name, $id, $default, $expected ) { + $this->select = new XmlSelect( $name, $id, $default ); + $this->assertEquals( $expected, $this->select->getHTML() ); + } + + /** + * Provide parameters for testConstructParameters() which use three + * parameters: + * - $name (default: false) + * - $id (default: false) + * - $default (default: false) + * Provides a fourth parameters representing the expected HTML output + */ + public static function provideConstructionParameters() { + return [ + /** + * Values are set following a 3-bit Gray code where two successive + * values differ by only one value. + * See https://en.wikipedia.org/wiki/Gray_code + */ + # $name $id $default + [ false, false, false, '' ], + [ false, false, 'foo', '' ], + [ false, 'id', 'foo', '' ], + [ false, 'id', false, '' ], + [ 'name', 'id', false, '' ], + [ 'name', 'id', 'foo', '' ], + [ 'name', false, 'foo', '' ], + [ 'name', false, false, '' ], + ]; + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOption() { + $this->select->addOption( 'foo' ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithDefault() { + $this->select->addOption( 'foo', true ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithFalse() { + $this->select->addOption( 'foo', false ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::addOption + */ + public function testAddOptionWithValueZero() { + $this->select->addOption( 'foo', 0 ); + $this->assertEquals( + '', + $this->select->getHTML() + ); + } + + /** + * @covers XmlSelect::setDefault + */ + public function testSetDefault() { + $this->select->setDefault( 'bar1' ); + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * Adding default later on should set the correct selection or + * raise an exception. + * To handle this, we need to render the options in getHtml() + * @covers XmlSelect::setDefault + */ + public function testSetDefaultAfterAddingOptions() { + $this->select->addOption( 'foo1' ); + $this->select->addOption( 'bar1' ); + $this->select->addOption( 'foo2' ); + $this->select->setDefault( 'bar1' ); # setting default after adding options + $this->assertEquals( + '', $this->select->getHTML() ); + } + + /** + * @covers XmlSelect::setAttribute + * @covers XmlSelect::getAttribute + */ + public function testGetAttributes() { + # create some attributes + $this->select->setAttribute( 'dummy', 0x777 ); + $this->select->setAttribute( 'string', 'euro €' ); + $this->select->setAttribute( 1911, 'razor' ); + + # verify we can retrieve them + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + $this->assertEquals( + $this->select->getAttribute( 'string' ), + 'euro €' + ); + $this->assertEquals( + $this->select->getAttribute( 1911 ), + 'razor' + ); + + # inexistent keys should give us 'null' + $this->assertEquals( + $this->select->getAttribute( 'I DO NOT EXIT' ), + null + ); + + # verify string / integer + $this->assertEquals( + $this->select->getAttribute( '1911' ), + 'razor' + ); + $this->assertEquals( + $this->select->getAttribute( 'dummy' ), + 0x777 + ); + } +} diff --git a/tests/phpunit/includes/actions/ViewActionTest.php b/tests/phpunit/includes/actions/ViewActionTest.php new file mode 100644 index 0000000000..5f659c07a2 --- /dev/null +++ b/tests/phpunit/includes/actions/ViewActionTest.php @@ -0,0 +1,35 @@ +makeViewActionClassFactory(); + $actual = $viewAction->getName(); + + $this->assertSame( 'view', $actual ); + } + + public function testOnView() { + $viewAction = $this->makeViewActionClassFactory(); + $actual = $viewAction->onView(); + + $this->assertNull( $actual ); + } +} diff --git a/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php b/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php new file mode 100644 index 0000000000..ba5c003776 --- /dev/null +++ b/tests/phpunit/includes/api/ApiBlockInfoTraitTest.php @@ -0,0 +1,43 @@ +getMockForTrait( ApiBlockInfoTrait::class ); + $info = TestingAccessWrapper::newFromObject( $mock )->getBlockDetails( $block ); + $subset = array_merge( [ + 'blockid' => null, + 'blockedby' => '', + 'blockedbyid' => 0, + 'blockreason' => '', + 'blockexpiry' => 'infinite', + ], $expectedInfo ); + $this->assertArraySubset( $subset, $info ); + } + + public static function provideGetBlockDetails() { + return [ + 'Sitewide block' => [ + new DatabaseBlock(), + [ 'blockpartial' => false ], + ], + 'Partial block' => [ + new DatabaseBlock( [ 'sitewide' => false ] ), + [ 'blockpartial' => true ], + ], + 'System block' => [ + new SystemBlock( [ 'systemBlock' => 'proxy' ] ), + [ 'systemblocktype' => 'proxy' ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/api/ApiContinuationManagerTest.php b/tests/phpunit/includes/api/ApiContinuationManagerTest.php new file mode 100644 index 0000000000..788d120c6b --- /dev/null +++ b/tests/phpunit/includes/api/ApiContinuationManagerTest.php @@ -0,0 +1,198 @@ +setRequest( new FauxRequest( [ 'continue' => $continue ] ) ); + $main = new ApiMain( $context ); + return new ApiContinuationManager( $main, $allModules, $generatedModules ); + } + + public function testContinuation() { + $allModules = [ + new MockApiQueryBase( 'mock1' ), + new MockApiQueryBase( 'mock2' ), + new MockApiQueryBase( 'mocklist' ), + ]; + $generator = new MockApiQueryBase( 'generator' ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( ApiMain::class, $manager->getSource() ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'mocklist' => [ 'mlcontinue' => 2 ], + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $result = new ApiResult( 0 ); + $manager->setContinuationIntoResult( $result ); + $this->assertSame( [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], $result->getResultData( 'continue' ) ); + $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] ); + $this->assertSame( [ [ + 'm1continue' => '1|2', + 'continue' => '||mock2|mocklist', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'generator' => [ 'gcontinue' => '3|4' ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'gcontinue' => 3, + 'continue' => 'gcontinue||', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'mocklist' => [ 'mlcontinue' => 2 ], + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $result = new ApiResult( 0 ); + $manager->setContinuationIntoResult( $result ); + $this->assertSame( [ + 'mlcontinue' => 2, + 'gcontinue' => 3, + 'continue' => 'gcontinue||', + ], $result->getResultData( 'continue' ) ); + $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'gcontinue' => 3, + 'continue' => 'gcontinue||mocklist', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'mocklist' => [ 'mlcontinue' => 2 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $this->assertSame( [ [ + 'm1continue' => '1|2', + 'continue' => '||mock2|mocklist', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'continue' => '-||mock1|mock2', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'mocklist' => [ 'mlcontinue' => 2 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $this->assertSame( [ [], true ], $manager->getContinuation() ); + $this->assertSame( [], $manager->getRawContinuation() ); + + $manager = self::getManager( '||mock2', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( + array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ), + $manager->getRunModules() + ); + + $manager = self::getManager( '-||', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( true, $manager->isGeneratorDone() ); + $this->assertSame( + array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ), + $manager->getRunModules() + ); + + try { + self::getManager( 'foo', $allModules, [ 'mock1', 'mock2' ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'badcontinue' ), + 'Expected exception' + ); + } + + $manager = self::getManager( + '||mock2', + array_slice( $allModules, 0, 2 ), + [ 'mock1', 'mock2' ] + ); + try { + $manager->addContinueParam( $allModules[1], 'm2continue', 1 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'Module \'mock2\' was not supposed to have been executed, ' . + 'but it was executed anyway', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $manager->addContinueParam( $allModules[2], 'mlcontinue', 1 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'Module \'mocklist\' called ApiContinuationManager::addContinueParam ' . + 'but was not passed to ApiContinuationManager::__construct', + $ex->getMessage(), + 'Expected exception' + ); + } + } + +} diff --git a/tests/phpunit/includes/api/ApiMessageTest.php b/tests/phpunit/includes/api/ApiMessageTest.php new file mode 100644 index 0000000000..70114c2593 --- /dev/null +++ b/tests/phpunit/includes/api/ApiMessageTest.php @@ -0,0 +1,196 @@ +assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' ); + $this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' ); + $this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' ); + $this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' ); + + $msg = TestingAccessWrapper::newFromObject( $msg ); + $msg2 = TestingAccessWrapper::newFromObject( $msg2 ); + $this->assertSame( $msg->interface, $msg2->interface, 'interface' ); + $this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' ); + $this->assertSame( $msg->format, $msg2->format, 'format' ); + $this->assertSame( + $msg->title ? $msg->title->getFullText() : null, + $msg2->title ? $msg2->title->getFullText() : null, + 'title' + ); + } + + /** + * @covers ApiMessageTrait + */ + public function testCodeDefaults() { + $msg = new ApiMessage( 'foo' ); + $this->assertSame( 'foo', $msg->getApiCode() ); + + $msg = new ApiMessage( 'apierror-bar' ); + $this->assertSame( 'bar', $msg->getApiCode() ); + + $msg = new ApiMessage( 'apiwarn-baz' ); + $this->assertSame( 'baz', $msg->getApiCode() ); + + // Weird "message key" + $msg = new ApiMessage( " bar\nbaz" ); + $this->assertSame( '_foo__bar_baz', $msg->getApiCode() ); + + // BC case + $msg = new ApiMessage( 'actionthrottledtext' ); + $this->assertSame( 'ratelimited', $msg->getApiCode() ); + + $msg = new ApiMessage( [ 'apierror-missingparam', 'param' ] ); + $this->assertSame( 'noparam', $msg->getApiCode() ); + } + + /** + * @covers ApiMessageTrait + * @dataProvider provideInvalidCode + * @param mixed $code + */ + public function testInvalidCode( $code ) { + $msg = new ApiMessage( 'foo' ); + try { + $msg->setApiCode( $code ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + + try { + new ApiMessage( 'foo', $code ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + } + + public static function provideInvalidCode() { + return [ + [ '' ], + [ 42 ], + [ 'A bad code' ], + [ 'Project:A_page_title' ], + [ "WTF\nnewlines" ], + ]; + } + + /** + * @covers ApiMessage + * @covers ApiMessageTrait + */ + public function testApiMessage() { + $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] ); + $msg->inLanguage( 'de' )->title( Title::newMainPage() ); + $msg2 = new ApiMessage( $msg, 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2 = unserialize( serialize( $msg2 ) ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] ); + $msg2 = new ApiMessage( [ [ 'foo', 'bar' ], 'baz' ], 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new Message( 'foo' ); + $msg2 = new ApiMessage( 'foo' ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [], $msg2->getApiData() ); + + $msg2->setApiCode( 'code', [ 'data' ] ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiCode( null ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiData( [ 'data2' ] ); + $this->assertEquals( [ 'data2' ], $msg2->getApiData() ); + } + + /** + * @covers ApiRawMessage + * @covers ApiMessageTrait + */ + public function testApiRawMessage() { + $msg = new RawMessage( 'foo', [ 'baz' ] ); + $msg->inLanguage( 'de' )->title( Title::newMainPage() ); + $msg2 = new ApiRawMessage( $msg, 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2 = unserialize( serialize( $msg2 ) ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new RawMessage( 'foo', [ 'baz' ] ); + $msg2 = new ApiRawMessage( [ 'foo', 'baz' ], 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new RawMessage( 'foo' ); + $msg2 = new ApiRawMessage( 'foo', 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2->setApiCode( 'code', [ 'data' ] ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiCode( null ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiData( [ 'data2' ] ); + $this->assertEquals( [ 'data2' ], $msg2->getApiData() ); + } + + /** + * @covers ApiMessage::create + */ + public function testApiMessageCreate() { + $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( new Message( 'mainpage' ) ) ); + $this->assertInstanceOf( + ApiRawMessage::class, ApiMessage::create( new RawMessage( 'mainpage' ) ) + ); + $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( 'mainpage' ) ); + + $msg = new ApiMessage( [ 'parentheses', 'foobar' ] ); + $msg2 = new Message( 'parentheses', [ 'foobar' ] ); + + $this->assertSame( $msg, ApiMessage::create( $msg ) ); + $this->assertEquals( $msg, ApiMessage::create( $msg2 ) ); + $this->assertEquals( $msg, ApiMessage::create( [ 'parentheses', 'foobar' ] ) ); + $this->assertEquals( $msg, + ApiMessage::create( [ 'message' => 'parentheses', 'params' => [ 'foobar' ] ] ) + ); + $this->assertSame( $msg, + ApiMessage::create( [ 'message' => $msg, 'params' => [ 'xxx' ] ] ) + ); + $this->assertEquals( $msg, + ApiMessage::create( [ 'message' => $msg2, 'params' => [ 'xxx' ] ] ) + ); + $this->assertSame( $msg, + ApiMessage::create( [ 'message' => $msg ] ) + ); + + $msg = new ApiRawMessage( [ 'parentheses', 'foobar' ] ); + $this->assertSame( $msg, ApiMessage::create( $msg ) ); + } + +} diff --git a/tests/phpunit/includes/api/ApiResultTest.php b/tests/phpunit/includes/api/ApiResultTest.php new file mode 100644 index 0000000000..98e24fb666 --- /dev/null +++ b/tests/phpunit/includes/api/ApiResultTest.php @@ -0,0 +1,1410 @@ +assertSame( [ + 'setValue' => '1', + 'unnamed 1', + 'unnamed 2', + ApiResult::META_CONTENT => 'setContentValue', + 'setContentValue' => '3', + ], $arr ); + + try { + ApiResult::setValue( $arr, 'setValue', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to add element setValue=99, existing value is 1', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + ApiResult::setContentValue( $arr, 'setContentValue2', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to set content element as setContentValue2 when setContentValue ' . + 'is already set as the content element', + $ex->getMessage(), + 'Expected exception' + ); + } + + ApiResult::setValue( $arr, 'setValue', '99', ApiResult::OVERRIDE ); + $this->assertSame( '99', $arr['setValue'] ); + + ApiResult::setContentValue( $arr, 'setContentValue2', '99', ApiResult::OVERRIDE ); + $this->assertSame( 'setContentValue2', $arr[ApiResult::META_CONTENT] ); + + $arr = [ 'foo' => 1, 'bar' => 1 ]; + ApiResult::setValue( $arr, 'top', '2', ApiResult::ADD_ON_TOP ); + ApiResult::setValue( $arr, null, '2', ApiResult::ADD_ON_TOP ); + ApiResult::setValue( $arr, 'bottom', '2' ); + ApiResult::setValue( $arr, 'foo', '2', ApiResult::OVERRIDE ); + ApiResult::setValue( $arr, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP ); + $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom' ], array_keys( $arr ) ); + + $arr = []; + ApiResult::setValue( $arr, 'sub', [ 'foo' => 1 ] ); + ApiResult::setValue( $arr, 'sub', [ 'bar' => 1 ] ); + $this->assertSame( [ 'sub' => [ 'foo' => 1, 'bar' => 1 ] ], $arr ); + + try { + ApiResult::setValue( $arr, 'sub', [ 'foo' => 2, 'baz' => 2 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Conflicting keys (foo) when attempting to merge element sub', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + $title = Title::newFromText( "MediaWiki:Foobar" ); + $obj = new stdClass; + $obj->foo = 1; + $obj->bar = 2; + ApiResult::setValue( $arr, 'title', $title ); + ApiResult::setValue( $arr, 'obj', $obj ); + $this->assertSame( [ + 'title' => (string)$title, + 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ], + ], $arr ); + + $fh = tmpfile(); + try { + ApiResult::setValue( $arr, 'file', $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + ApiResult::setValue( $arr, 'sub', $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + ApiResult::setValue( $arr, null, $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + fclose( $fh ); + + try { + ApiResult::setValue( $arr, 'inf', INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, 'nan', NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + ApiResult::setValue( $arr, null, NAN, ApiResult::NO_VALIDATE ); + + try { + ApiResult::setValue( $arr, null, NAN, ApiResult::NO_SIZE_CHECK ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + $result2 = new ApiResult( 8388608 ); + $result2->addValue( null, 'foo', 'bar' ); + ApiResult::setValue( $arr, 'baz', $result2 ); + $this->assertSame( [ + 'baz' => [ + ApiResult::META_TYPE => 'assoc', + 'foo' => 'bar', + ] + ], $arr ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', "foo\x80bar" ); + ApiResult::setValue( $arr, 'bar', "a\xcc\x81" ); + ApiResult::setValue( $arr, 'baz', 74 ); + ApiResult::setValue( $arr, null, "foo\x80bar" ); + ApiResult::setValue( $arr, null, "a\xcc\x81" ); + $this->assertSame( [ + 'foo' => "foo\xef\xbf\xbdbar", + 'bar' => "\xc3\xa1", + 'baz' => 74, + 0 => "foo\xef\xbf\xbdbar", + 1 => "\xc3\xa1", + ], $arr ); + + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = []; + ApiResult::setValue( $arr, 'foo', $obj ); + $this->assertSame( [ + 'foo' => [ + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ] + ], $arr ); + } + + /** + * @covers ApiResult + */ + public function testInstanceDataMethods() { + $result = new ApiResult( 8388608 ); + + $result->addValue( null, 'setValue', '1' ); + + $result->addValue( null, null, 'unnamed 1' ); + $result->addValue( null, null, 'unnamed 2' ); + + $result->addValue( null, 'deleteValue', '2' ); + $result->removeValue( null, 'deleteValue' ); + + $result->addValue( [ 'a', 'b' ], 'deleteValue', '3' ); + $result->removeValue( [ 'a', 'b', 'deleteValue' ], null, '3' ); + + $result->addContentValue( null, 'setContentValue', '3' ); + + $this->assertSame( [ + 'setValue' => '1', + 'unnamed 1', + 'unnamed 2', + 'a' => [ 'b' => [] ], + 'setContentValue' => '3', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_CONTENT => 'setContentValue', + ], $result->getResultData() ); + $this->assertSame( 20, $result->getSize() ); + + try { + $result->addValue( null, 'setValue', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to add element setValue=99, existing value is 1', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + $result->addContentValue( null, 'setContentValue2', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to set content element as setContentValue2 when setContentValue ' . + 'is already set as the content element', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->addValue( null, 'setValue', '99', ApiResult::OVERRIDE ); + $this->assertSame( '99', $result->getResultData( [ 'setValue' ] ) ); + + $result->addContentValue( null, 'setContentValue2', '99', ApiResult::OVERRIDE ); + $this->assertSame( 'setContentValue2', + $result->getResultData( [ ApiResult::META_CONTENT ] ) ); + + $result->reset(); + $this->assertSame( [ + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $this->assertSame( 0, $result->getSize() ); + + $result->addValue( null, 'foo', 1 ); + $result->addValue( null, 'bar', 1 ); + $result->addValue( null, 'top', '2', ApiResult::ADD_ON_TOP ); + $result->addValue( null, null, '2', ApiResult::ADD_ON_TOP ); + $result->addValue( null, 'bottom', '2' ); + $result->addValue( null, 'foo', '2', ApiResult::OVERRIDE ); + $result->addValue( null, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP ); + $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom', ApiResult::META_TYPE ], + array_keys( $result->getResultData() ) ); + + $result->reset(); + $result->addValue( null, 'foo', [ 'bar' => 1 ] ); + $result->addValue( [ 'foo', 'top' ], 'x', 2, ApiResult::ADD_ON_TOP ); + $result->addValue( [ 'foo', 'bottom' ], 'x', 2 ); + $this->assertSame( [ 'top', 'bar', 'bottom' ], + array_keys( $result->getResultData( [ 'foo' ] ) ) ); + + $result->reset(); + $result->addValue( null, 'sub', [ 'foo' => 1 ] ); + $result->addValue( null, 'sub', [ 'bar' => 1 ] ); + $this->assertSame( [ + 'sub' => [ 'foo' => 1, 'bar' => 1 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + try { + $result->addValue( null, 'sub', [ 'foo' => 2, 'baz' => 2 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Conflicting keys (foo) when attempting to merge element sub', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->reset(); + $title = Title::newFromText( "MediaWiki:Foobar" ); + $obj = new stdClass; + $obj->foo = 1; + $obj->bar = 2; + $result->addValue( null, 'title', $title ); + $result->addValue( null, 'obj', $obj ); + $this->assertSame( [ + 'title' => (string)$title, + 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $fh = tmpfile(); + try { + $result->addValue( null, 'file', $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + $result->addValue( null, 'sub', $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + $result->addValue( null, null, $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + fclose( $fh ); + + try { + $result->addValue( null, 'inf', INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, 'nan', NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->addValue( null, null, NAN, ApiResult::NO_VALIDATE ); + + try { + $result->addValue( null, null, NAN, ApiResult::NO_SIZE_CHECK ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->reset(); + $result->addParsedLimit( 'foo', 12 ); + $this->assertSame( [ + 'limits' => [ 'foo' => 12 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $result->addParsedLimit( 'foo', 13 ); + $this->assertSame( [ + 'limits' => [ 'foo' => 13 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $this->assertSame( null, $result->getResultData( [ 'foo', 'bar', 'baz' ] ) ); + $this->assertSame( 13, $result->getResultData( [ 'limits', 'foo' ] ) ); + try { + $result->getResultData( [ 'limits', 'foo', 'bar' ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Path limits.foo is not an array', + $ex->getMessage(), + 'Expected exception' + ); + } + + // Add two values and some metadata, but ensure metadata is not counted + $result = new ApiResult( 100 ); + $obj = [ 'attr' => '12345' ]; + ApiResult::setContentValue( $obj, 'content', '1234567890' ); + $this->assertTrue( $result->addValue( null, 'foo', $obj ) ); + $this->assertSame( 15, $result->getSize() ); + + $result = new ApiResult( 10 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'none', false ); + $result->setErrorFormatter( $formatter ); + $this->assertFalse( $result->addValue( null, 'foo', '12345678901' ) ); + $this->assertTrue( $result->addValue( null, 'foo', '12345678901', ApiResult::NO_SIZE_CHECK ) ); + $this->assertSame( 0, $result->getSize() ); + $result->reset(); + $this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) ); + $this->assertFalse( $result->addValue( null, 'foo', '1' ) ); + $result->removeValue( null, 'foo' ); + $this->assertTrue( $result->addValue( null, 'foo', '1' ) ); + + $result = new ApiResult( 10 ); + $obj = new ApiResultTestSerializableObject( 'ok' ); + $obj->foobar = 'foobaz'; + $this->assertTrue( $result->addValue( null, 'foo', $obj ) ); + $this->assertSame( 2, $result->getSize() ); + + $result = new ApiResult( 8388608 ); + $result2 = new ApiResult( 8388608 ); + $result2->addValue( null, 'foo', 'bar' ); + $result->addValue( null, 'baz', $result2 ); + $this->assertSame( [ + 'baz' => [ + 'foo' => 'bar', + ApiResult::META_TYPE => 'assoc', + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $result = new ApiResult( 8388608 ); + $result->addValue( null, 'foo', "foo\x80bar" ); + $result->addValue( null, 'bar', "a\xcc\x81" ); + $result->addValue( null, 'baz', 74 ); + $result->addValue( null, null, "foo\x80bar" ); + $result->addValue( null, null, "a\xcc\x81" ); + $this->assertSame( [ + 'foo' => "foo\xef\xbf\xbdbar", + 'bar' => "\xc3\xa1", + 'baz' => 74, + 0 => "foo\xef\xbf\xbdbar", + 1 => "\xc3\xa1", + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $result = new ApiResult( 8388608 ); + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = []; + $result->addValue( $arr, 'foo', $obj ); + $this->assertSame( [ + 'foo' => [ + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + } + + /** + * @covers ApiResult + */ + public function testMetadata() { + $arr = [ 'foo' => [ 'bar' => [] ] ]; + $result = new ApiResult( 8388608 ); + $result->addValue( null, 'foo', [ 'bar' => [] ] ); + + $expect = [ + 'foo' => [ + 'bar' => [ + ApiResult::META_INDEXED_TAG_NAME => 'ritn', + ApiResult::META_TYPE => 'default', + ], + ApiResult::META_INDEXED_TAG_NAME => 'ritn', + ApiResult::META_TYPE => 'default', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar' ], + ApiResult::META_TYPE => 'array', + ]; + + ApiResult::setSubelementsList( $arr, 'foo' ); + ApiResult::setSubelementsList( $arr, [ 'bar', 'baz' ] ); + ApiResult::unsetSubelementsList( $arr, 'baz' ); + ApiResult::setIndexedTagNameRecursive( $arr, 'ritn' ); + ApiResult::setIndexedTagName( $arr, 'itn' ); + ApiResult::setPreserveKeysList( $arr, 'foo' ); + ApiResult::setPreserveKeysList( $arr, [ 'bar', 'baz' ] ); + ApiResult::unsetPreserveKeysList( $arr, 'baz' ); + ApiResult::setArrayTypeRecursive( $arr, 'default' ); + ApiResult::setArrayType( $arr, 'array' ); + $this->assertSame( $expect, $arr ); + + $result->addSubelementsList( null, 'foo' ); + $result->addSubelementsList( null, [ 'bar', 'baz' ] ); + $result->removeSubelementsList( null, 'baz' ); + $result->addIndexedTagNameRecursive( null, 'ritn' ); + $result->addIndexedTagName( null, 'itn' ); + $result->addPreserveKeysList( null, 'foo' ); + $result->addPreserveKeysList( null, [ 'bar', 'baz' ] ); + $result->removePreserveKeysList( null, 'baz' ); + $result->addArrayTypeRecursive( null, 'default' ); + $result->addArrayType( null, 'array' ); + $this->assertEquals( $expect, $result->getResultData() ); + + $arr = [ 'foo' => [ 'bar' => [] ] ]; + $expect = [ + 'foo' => [ + 'bar' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'bc', + ]; + ApiResult::setArrayTypeRecursive( $arr, 'kvp', 'key' ); + ApiResult::setArrayType( $arr, 'BCkvp', 'bc' ); + $this->assertSame( $expect, $arr ); + } + + /** + * @covers ApiResult + */ + public function testUtilityFunctions() { + $arr = [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + '_dummy2' => 'foobaz!', + ]; + $this->assertEquals( [ + 'foo' => [ + 'bar' => [], + 'bar2' => (object)[], + 'x' => 'ok', + ], + 'foo2' => (object)[ + 'bar' => [], + 'bar2' => (object)[], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ], ApiResult::stripMetadata( $arr ), 'ApiResult::stripMetadata' ); + + $metadata = []; + $data = ApiResult::stripMetadataNonRecursive( $arr, $metadata ); + $this->assertEquals( [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], $data, 'ApiResult::stripMetadataNonRecursive ($data)' ); + $this->assertEquals( [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + ], $metadata, 'ApiResult::stripMetadataNonRecursive ($metadata)' ); + + $metadata = null; + $data = ApiResult::stripMetadataNonRecursive( (object)$arr, $metadata ); + $this->assertEquals( (object)[ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], $data, 'ApiResult::stripMetadataNonRecursive on object ($data)' ); + $this->assertEquals( [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + ], $metadata, 'ApiResult::stripMetadataNonRecursive on object ($metadata)' ); + } + + /** + * @covers ApiResult + * @dataProvider provideTransformations + * @param string $label + * @param array $input + * @param array $transforms + * @param array|Exception $expect + */ + public function testTransformations( $label, $input, $transforms, $expect ) { + $result = new ApiResult( false ); + $result->addValue( null, 'test', $input ); + + if ( $expect instanceof Exception ) { + try { + $output = $result->getResultData( 'test', $transforms ); + $this->fail( 'Expected exception not thrown', $label ); + } catch ( Exception $ex ) { + $this->assertEquals( $ex, $expect, $label ); + } + } else { + $output = $result->getResultData( 'test', $transforms ); + $this->assertEquals( $expect, $output, $label ); + } + } + + public function provideTransformations() { + $kvp = function ( $keyKey, $key, $valKey, $value ) { + return [ + $keyKey => $key, + $valKey => $value, + ApiResult::META_PRESERVE_KEYS => [ $keyKey ], + ApiResult::META_CONTENT => $valKey, + ApiResult::META_TYPE => 'assoc', + ]; + }; + $typeArr = [ + 'defaultArray' => [ 2 => 'a', 0 => 'b', 1 => 'c' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c' ], + 'array' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'BCarray' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'BCassoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ 'x' => 'a', 'y' => 'b', 'z' => [ 'c' ], ApiResult::META_TYPE => 'kvp' ], + 'BCkvp' => [ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ 'x' => 'a', 'y' => [ 'b' ], 'z' => [ 'c' => 'd' ], + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1 ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ]; + $stripArr = [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + '_dummy2' => 'foobaz!', + ]; + + return [ + [ + 'BC: META_BC_BOOLS', + [ + 'BCtrue' => true, + 'BCfalse' => false, + 'true' => true, + 'false' => false, + ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ], + ], + [ 'BC' => [] ], + [ + 'BCtrue' => '', + 'true' => true, + 'false' => false, + ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ], + ] + ], + [ + 'BC: META_BC_SUBELEMENTS', + [ + 'bc' => 'foo', + 'nobc' => 'bar', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + [ 'BC' => [] ], + [ + 'bc' => [ + '*' => 'foo', + ApiResult::META_CONTENT => '*', + ApiResult::META_TYPE => 'assoc', + ], + 'nobc' => 'bar', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + ], + [ + 'BC: META_CONTENT', + [ + 'content' => '!!!', + ApiResult::META_CONTENT => 'content', + ], + [ 'BC' => [] ], + [ + '*' => '!!!', + ApiResult::META_CONTENT => '*', + ], + ], + [ + 'BC: BCkvp type', + [ + 'foo' => 'foo value', + 'bar' => 'bar value', + '_baz' => 'baz value', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + [ 'BC' => [] ], + [ + $kvp( 'key', 'foo', '*', 'foo value' ), + $kvp( 'key', 'bar', '*', 'bar value' ), + $kvp( 'key', '_baz', '*', 'baz value' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ], + [ + 'BC: BCarray type', + [ + ApiResult::META_TYPE => 'BCarray', + ], + [ 'BC' => [] ], + [ + ApiResult::META_TYPE => 'default', + ], + ], + [ + 'BC: BCassoc type', + [ + ApiResult::META_TYPE => 'BCassoc', + ], + [ 'BC' => [] ], + [ + ApiResult::META_TYPE => 'default', + ], + ], + [ + 'BC: BCkvp exception', + [ + ApiResult::META_TYPE => 'BCkvp', + ], + [ 'BC' => [] ], + new UnexpectedValueException( + 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item' + ), + ], + [ + 'BC: nobool, no*, nosub', + [ + 'true' => true, + 'false' => false, + 'content' => 'content', + ApiResult::META_CONTENT => 'content', + 'bc' => 'foo', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + 'BCarray' => [ ApiResult::META_TYPE => 'BCarray' ], + 'BCassoc' => [ ApiResult::META_TYPE => 'BCassoc' ], + 'BCkvp' => [ + 'foo' => 'foo value', + 'bar' => 'bar value', + '_baz' => 'baz value', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ], + [ 'BC' => [ 'nobool', 'no*', 'nosub' ] ], + [ + 'true' => true, + 'false' => false, + 'content' => 'content', + 'bc' => 'foo', + 'BCarray' => [ ApiResult::META_TYPE => 'default' ], + 'BCassoc' => [ ApiResult::META_TYPE => 'default' ], + 'BCkvp' => [ + $kvp( 'key', 'foo', '*', 'foo value' ), + $kvp( 'key', 'bar', '*', 'bar value' ), + $kvp( 'key', '_baz', '*', 'baz value' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ApiResult::META_CONTENT => 'content', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + ], + + [ + 'Types: Normal transform', + $typeArr, + [ 'Types' => [] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ 'x' => 'a', 'y' => 'b', + 'z' => [ 'c', ApiResult::META_TYPE => 'array' ], + ApiResult::META_TYPE => 'assoc' + ], + 'BCkvp' => [ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + 'x' => 'a', + 'y' => [ 'b', ApiResult::META_TYPE => 'array' ], + 'z' => [ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: AssocAsObject', + $typeArr, + [ 'Types' => [ 'AssocAsObject' => true ] ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => (object)[ 'x' => 'a', + 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => (object)[ 'x' => 'a', 'y' => 'b', + 'z' => [ 'c', ApiResult::META_TYPE => 'array' ], + ApiResult::META_TYPE => 'assoc' + ], + 'BCkvp' => (object)[ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => (object)[ + 'x' => 'a', + 'y' => [ 'b', ApiResult::META_TYPE => 'array' ], + 'z' => (object)[ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name' ] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + $kvp( 'name', 'x', 'value', 'a' ), + $kvp( 'name', 'y', 'value', 'b' ), + $kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + $kvp( 'key', 'x', 'value', 'a' ), + $kvp( 'key', 'y', 'value', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + $kvp( 'name', 'x', 'value', 'a' ), + $kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ), + [ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] + ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP + BC', + $typeArr, + [ 'BC' => [], 'Types' => [ 'ArmorKVP' => 'name' ] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'array' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + $kvp( 'name', 'x', '*', 'a' ), + $kvp( 'name', 'y', '*', 'b' ), + $kvp( 'name', 'z', '*', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + $kvp( 'key', 'x', '*', 'a' ), + $kvp( 'key', 'y', '*', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + $kvp( 'name', 'x', '*', 'a' ), + $kvp( 'name', 'y', '*', [ 'b', ApiResult::META_TYPE => 'array' ] ), + [ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP + AssocAsObject', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ] ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + (object)$kvp( 'name', 'x', 'value', 'a' ), + (object)$kvp( 'name', 'y', 'value', 'b' ), + (object)$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + (object)$kvp( 'key', 'x', 'value', 'a' ), + (object)$kvp( 'key', 'y', 'value', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + (object)$kvp( 'name', 'x', 'value', 'a' ), + (object)$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ), + (object)[ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] + ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: BCkvp exception', + [ + ApiResult::META_TYPE => 'BCkvp', + ], + [ 'Types' => [] ], + new UnexpectedValueException( + 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item' + ), + ], + + [ + 'Strip: With ArmorKVP + AssocAsObject transforms', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ], 'Strip' => 'all' ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a' ], + 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', 0 => 'c' ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', 0 => 'c' ], + 'array' => [ 'a', 'c', 'b' ], + 'BCarray' => [ 'a', 'c', 'b' ], + 'BCassoc' => (object)[ 'a', 'b', 'c' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c' ], + 'kvp' => [ + (object)[ 'name' => 'x', 'value' => 'a' ], + (object)[ 'name' => 'y', 'value' => 'b' ], + (object)[ 'name' => 'z', 'value' => [ 'c' ] ], + ], + 'BCkvp' => [ + (object)[ 'key' => 'x', 'value' => 'a' ], + (object)[ 'key' => 'y', 'value' => 'b' ], + ], + 'kvpmerge' => [ + (object)[ 'name' => 'x', 'value' => 'a' ], + (object)[ 'name' => 'y', 'value' => [ 'b' ] ], + (object)[ 'name' => 'z', 'c' => 'd' ], + ], + 'emptyDefault' => [], + 'emptyAssoc' => (object)[], + '_dummy' => 1, + ], + ], + + [ + 'Strip: all', + $stripArr, + [ 'Strip' => 'all' ], + [ + 'foo' => [ + 'bar' => [], + 'baz' => [], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ], + ], + [ + 'Strip: base', + $stripArr, + [ 'Strip' => 'base' ], + [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], + ], + [ + 'Strip: bc', + $stripArr, + [ 'Strip' => 'bc' ], + [ + 'foo' => [ + 'bar' => [], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ], + ], + + [ + 'Custom transform', + [ + 'foo' => '?', + 'bar' => '?', + '_dummy' => '?', + '_dummy2' => '?', + '_dummy3' => '?', + ApiResult::META_CONTENT => 'foo', + ApiResult::META_PRESERVE_KEYS => [ '_dummy2', '_dummy3' ], + ], + [ + 'Custom' => [ $this, 'customTransform' ], + 'BC' => [], + 'Types' => [], + 'Strip' => 'all' + ], + [ + '*' => 'FOO', + 'bar' => 'BAR', + 'baz' => [ 'a', 'b' ], + '_dummy2' => '_DUMMY2', + '_dummy3' => '_DUMMY3', + ApiResult::META_CONTENT => 'bar', + ], + ], + ]; + } + + /** + * Custom transformer for testTransformations + * @param array &$data + * @param array &$metadata + */ + public function customTransform( &$data, &$metadata ) { + // Prevent recursion + if ( isset( $metadata['_added'] ) ) { + $metadata[ApiResult::META_TYPE] = 'array'; + return; + } + + foreach ( $data as $k => $v ) { + $data[$k] = strtoupper( $k ); + } + $data['baz'] = [ '_added' => 1, 'z' => 'b', 'y' => 'a' ]; + $metadata[ApiResult::META_PRESERVE_KEYS][0] = '_dummy'; + $data[ApiResult::META_CONTENT] = 'bar'; + } + + /** + * @covers ApiResult + */ + public function testAddMetadataToResultVars() { + $arr = [ + 'a' => "foo", + 'b' => false, + 'c' => 10, + 'sequential_numeric_keys' => [ 'a', 'b', 'c' ], + 'non_sequential_numeric_keys' => [ 'a', 'b', 4 => 'c' ], + 'string_keys' => [ + 'one' => 1, + 'two' => 2 + ], + 'object_sequential_keys' => (object)[ 'a', 'b', 'c' ], + '_type' => "should be overwritten in result", + ]; + $this->assertSame( [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ + 'a', 'b', 'c', + 'sequential_numeric_keys', 'non_sequential_numeric_keys', + 'string_keys', 'object_sequential_keys' + ], + ApiResult::META_BC_BOOLS => [ 'b' ], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 'a' => "foo", + 'b' => false, + 'c' => 10, + 'sequential_numeric_keys' => [ + ApiResult::META_TYPE => 'array', + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'value', + 0 => 'a', + 1 => 'b', + 2 => 'c', + ], + 'non_sequential_numeric_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 0, 1, 4 ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 0 => 'a', + 1 => 'b', + 4 => 'c', + ], + 'string_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 'one', 'two' ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 'one' => 1, + 'two' => 2, + ], + 'object_sequential_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 0, 1, 2 ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 0 => 'a', + 1 => 'b', + 2 => 'c', + ], + ], ApiResult::addMetadataToResultVars( $arr ) ); + } + + public function testObjectSerialization() { + $arr = []; + ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] ); + $this->assertSame( [ + 'a' => 1, + 'b' => 2, + ApiResult::META_TYPE => 'assoc', + ], $arr['foo'] ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestStringifiableObject() ); + $this->assertSame( 'Ok', $arr['foo'] ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( 'Ok' ) ); + $this->assertSame( 'Ok', $arr['foo'] ); + + try { + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( + new ApiResultTestStringifiableObject() + ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'ApiResultTestSerializableObject::serializeForApiResult() ' . + 'returned an object of class ApiResultTestStringifiableObject', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( NAN ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'ApiResultTestSerializableObject::serializeForApiResult() ' . + 'returned an invalid value: Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( + [ + 'one' => new ApiResultTestStringifiableObject( '1' ), + 'two' => new ApiResultTestSerializableObject( 2 ), + ] + ) ); + $this->assertSame( [ + 'one' => '1', + 'two' => 2, + ], $arr['foo'] ); + } +} + +class ApiResultTestStringifiableObject { + private $ret; + + public function __construct( $ret = 'Ok' ) { + $this->ret = $ret; + } + + public function __toString() { + return $this->ret; + } +} + +class ApiResultTestSerializableObject { + private $ret; + + public function __construct( $ret ) { + $this->ret = $ret; + } + + public function __toString() { + return "Fail"; + } + + public function serializeForApiResult() { + return $this->ret; + } +} diff --git a/tests/phpunit/includes/api/ApiUsageExceptionTest.php b/tests/phpunit/includes/api/ApiUsageExceptionTest.php new file mode 100644 index 0000000000..bb72021121 --- /dev/null +++ b/tests/phpunit/includes/api/ApiUsageExceptionTest.php @@ -0,0 +1,44 @@ +fatal( $messageKey, $messageParameter ); + + $apiUsageException = new ApiUsageException( null, $statusValue ); + /** @var \Message $gotMessage */ + $gotMessage = $apiUsageException->getMessageObject(); + + $this->assertInstanceOf( \Message::class, $gotMessage ); + $this->assertEquals( $messageKey, $gotMessage->getKey() ); + $this->assertEquals( [ $messageParameter ], $gotMessage->getParams() ); + } + + public function testNewWithMessage_ThenGetMessageObject_ReturnsApiMessageWithProvidedData() { + $expectedMessage = new Message( 'some-message-key', [ 'some message parameter' ] ); + $expectedCode = 'some-error-code'; + $expectedData = [ 'some-error-data' ]; + + $apiUsageException = ApiUsageException::newWithMessage( + null, + $expectedMessage, + $expectedCode, + $expectedData + ); + /** @var \ApiMessage $gotMessage */ + $gotMessage = $apiUsageException->getMessageObject(); + + $this->assertInstanceOf( \ApiMessage::class, $gotMessage ); + $this->assertEquals( $expectedMessage->getKey(), $gotMessage->getKey() ); + $this->assertEquals( $expectedMessage->getParams(), $gotMessage->getParams() ); + $this->assertEquals( $expectedCode, $gotMessage->getApiCode() ); + $this->assertEquals( $expectedData, $gotMessage->getApiData() ); + } + +} diff --git a/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php new file mode 100644 index 0000000000..2970a2807c --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractPreAuthenticationProviderTest.php @@ -0,0 +1,45 @@ +getMockForAbstractClass( AbstractPreAuthenticationProvider::class ); + + $this->assertEquals( + [], + $provider->getAuthenticationRequests( AuthManager::ACTION_LOGIN, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAuthentication( [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountLink( $user ) + ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + $provider->postAccountLink( $user, $res ); + } +} diff --git a/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..cd17862838 --- /dev/null +++ b/tests/phpunit/includes/auth/AbstractSecondaryAuthenticationProviderTest.php @@ -0,0 +1,84 @@ +getMockForAbstractClass( AbstractSecondaryAuthenticationProvider::class ); + + try { + $provider->continueSecondaryAuthentication( $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + try { + $provider->continueSecondaryAccountCreation( $user, $user, [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + } + + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + + $this->assertTrue( $provider->providerAllowsPropertyChange( 'foo' ) ); + $this->assertEquals( + \StatusValue::newGood( 'ignored' ), + $provider->providerAllowsAuthenticationDataChange( $req ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testForAccountCreation( $user, $user, [] ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ) + ); + $this->assertEquals( + \StatusValue::newGood(), + $provider->testUserForCreation( $user, false ) + ); + + $provider->providerChangeAuthenticationData( $req ); + $provider->autoCreatedAccount( $user, AuthManager::AUTOCREATE_SOURCE_SESSION ); + + $res = AuthenticationResponse::newPass(); + $provider->postAuthentication( $user, $res ); + $provider->postAccountCreation( $user, $user, $res ); + } + + public function testProviderRevokeAccessForUser() { + $reqs = []; + for ( $i = 0; $i < 3; $i++ ) { + $reqs[$i] = $this->createMock( AuthenticationRequest::class ); + $reqs[$i]->done = false; + } + + $provider = $this->getMockBuilder( AbstractSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'providerChangeAuthenticationData' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->once() )->method( 'getAuthenticationRequests' ) + ->with( + $this->identicalTo( AuthManager::ACTION_REMOVE ), + $this->identicalTo( [ 'username' => 'UTSysop' ] ) + ) + ->will( $this->returnValue( $reqs ) ); + $provider->expects( $this->exactly( 3 ) )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) { + $this->assertSame( 'UTSysop', $req->username ); + $this->assertFalse( $req->done ); + $req->done = true; + } ) ); + + $provider->providerRevokeAccessForUser( 'UTSysop' ); + + foreach ( $reqs as $i => $req ) { + $this->assertTrue( $req->done, "#$i" ); + } + } +} diff --git a/tests/phpunit/includes/auth/AuthenticationResponseTest.php b/tests/phpunit/includes/auth/AuthenticationResponseTest.php new file mode 100644 index 0000000000..c79682275f --- /dev/null +++ b/tests/phpunit/includes/auth/AuthenticationResponseTest.php @@ -0,0 +1,112 @@ +messageType = 'warning'; + foreach ( $expect as $field => $value ) { + $res->$field = $value; + } + $ret = call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->assertEquals( $res, $ret ); + } else { + try { + call_user_func_array( "MediaWiki\\Auth\\AuthenticationResponse::$constructor", $args ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \Exception $ex ) { + $this->assertEquals( $expect, $ex ); + } + } + } + + public function provideConstructors() { + $req = $this->getMockForAbstractClass( AuthenticationRequest::class ); + $msg = new \Message( 'mainpage' ); + + return [ + [ 'newPass', [], [ + 'status' => AuthenticationResponse::PASS, + ] ], + [ 'newPass', [ 'name' ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + [ 'newPass', [ 'name', null ], [ + 'status' => AuthenticationResponse::PASS, + 'username' => 'name', + ] ], + + [ 'newFail', [ $msg ], [ + 'status' => AuthenticationResponse::FAIL, + 'message' => $msg, + 'messageType' => 'error', + ] ], + + [ 'newRestart', [ $msg ], [ + 'status' => AuthenticationResponse::RESTART, + 'message' => $msg, + ] ], + + [ 'newAbstain', [], [ + 'status' => AuthenticationResponse::ABSTAIN, + ] ], + + [ 'newUI', [ [ $req ], $msg ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'warning' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'warning', + ] ], + + [ 'newUI', [ [ $req ], $msg, 'error' ], [ + 'status' => AuthenticationResponse::UI, + 'neededRequests' => [ $req ], + 'message' => $msg, + 'messageType' => 'error', + ] ], + [ 'newUI', [ [], $msg ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + + [ 'newRedirect', [ [ $req ], 'http://example.org/redir' ], [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + ] ], + [ + 'newRedirect', + [ [ $req ], 'http://example.org/redir', [ 'foo' => 'bar' ] ], + [ + 'status' => AuthenticationResponse::REDIRECT, + 'neededRequests' => [ $req ], + 'redirectTarget' => 'http://example.org/redir', + 'redirectApiData' => [ 'foo' => 'bar' ], + ] + ], + [ 'newRedirect', [ [], 'http://example.org/redir' ], + new \InvalidArgumentException( '$reqs may not be empty' ) + ], + ]; + } + +} diff --git a/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..b17da2e2cf --- /dev/null +++ b/tests/phpunit/includes/auth/ConfirmLinkSecondaryAuthenticationProviderTest.php @@ -0,0 +1,289 @@ +assertEquals( $response, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public static function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::authnState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAuthentication( $user, [] ) ); + } + + public function testContinueSecondaryAuthentication() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::authnState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAuthentication( $user, $reqs ) ); + } + + public function testBeginSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'AuthManager::accountCreationState' ) ) + ->will( $this->returnValue( $obj ) ); + $mock->expects( $this->never() )->method( 'continueLinkAttempt' ); + + $this->assertSame( $obj, $mock->beginSecondaryAccountCreation( $user, $user, [] ) ); + } + + public function testContinueSecondaryAccountCreation() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = [ new \stdClass ]; + + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt', 'continueLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->never() )->method( 'beginLinkAttempt' ); + $mock->expects( $this->once() )->method( 'continueLinkAttempt' ) + ->with( + $this->identicalTo( $user ), + $this->identicalTo( 'AuthManager::accountCreationState' ), + $this->identicalTo( $reqs ) + ) + ->will( $this->returnValue( $obj ) ); + + $this->assertSame( $obj, $mock->continueSecondaryAccountCreation( $user, $user, $reqs ) ); + } + + /** + * Get requests for testing + * @return AuthenticationRequest[] + */ + private function getLinkRequests() { + $reqs = []; + + $mb = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ); + for ( $i = 1; $i <= 3; $i++ ) { + $req = $mb->getMockForAbstractClass(); + $req->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "Request$i" ) ); + $req->id = $i - 1; + $reqs[$req->getUniqueId()] = $req; + } + + return $reqs; + } + + public function testBeginLinkAttempt() { + $badReq = $this->getMockBuilder( AuthenticationRequest::class ) + ->setMethods( [ 'getUniqueId' ] ) + ->getMockForAbstractClass(); + $badReq->expects( $this->any() )->method( 'getUniqueId' ) + ->will( $this->returnValue( "BadReq" ) ); + + $user = \User::newFromName( 'UTSysop' ); + $provider = TestingAccessWrapper::newFromObject( + new ConfirmLinkSecondaryAuthenticationProvider + ); + $request = new \FauxRequest(); + $manager = $this->getMockBuilder( AuthManager::class ) + ->setMethods( [ 'allowsAuthenticationDataChange' ] ) + ->setConstructorArgs( [ $request, \RequestContext::getMain()->getConfig() ] ) + ->getMock(); + $manager->expects( $this->any() )->method( 'allowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) { + return $req->getUniqueId() !== 'BadReq' + ? \StatusValue::newGood() + : \StatusValue::newFatal( 'no' ); + } ) ); + $provider->setManager( $manager ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->beginLinkAttempt( $user, 'state' ) + ); + + $reqs = $this->getLinkRequests(); + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + [ 'BadReq' => $badReq ] + ] ); + $res = $provider->beginLinkAttempt( $user, 'state' ); + $this->assertInstanceOf( AuthenticationResponse::class, $res ); + $this->assertSame( AuthenticationResponse::UI, $res->status ); + $this->assertSame( 'authprovider-confirmlink-message', $res->message->getKey() ); + $this->assertCount( 1, $res->neededRequests ); + $req = $res->neededRequests[0]; + $this->assertInstanceOf( ConfirmLinkAuthenticationRequest::class, $req ); + $expectReqs = $this->getLinkRequests(); + foreach ( $expectReqs as $r ) { + $r->action = AuthManager::ACTION_CHANGE; + $r->username = $user->getName(); + } + $this->assertEquals( $expectReqs, TestingAccessWrapper::newFromObject( $req )->linkRequests ); + } + + public function testContinueLinkAttempt() { + $user = \User::newFromName( 'UTSysop' ); + $obj = new \stdClass; + $reqs = $this->getLinkRequests(); + + $done = [ false, false, false ]; + + // First, test the pass-through for not containing the ConfirmLinkAuthenticationRequest + $mock = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ 'beginLinkAttempt' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'beginLinkAttempt' ) + ->with( $this->identicalTo( $user ), $this->identicalTo( 'state' ) ) + ->will( $this->returnValue( $obj ) ); + $this->assertSame( + $obj, + TestingAccessWrapper::newFromObject( $mock )->continueLinkAttempt( $user, 'state', $reqs ) + ); + + // Now test the actual functioning + $provider = $this->getMockBuilder( ConfirmLinkSecondaryAuthenticationProvider::class ) + ->setMethods( [ + 'beginLinkAttempt', 'providerAllowsAuthenticationDataChange', + 'providerChangeAuthenticationData' + ] ) + ->getMock(); + $provider->expects( $this->never() )->method( 'beginLinkAttempt' ); + $provider->expects( $this->any() )->method( 'providerAllowsAuthenticationDataChange' ) + ->will( $this->returnCallback( function ( $req ) use ( $reqs ) { + return $req->getUniqueId() === 'Request3' + ? \StatusValue::newFatal( 'foo' ) : \StatusValue::newGood(); + } ) ); + $provider->expects( $this->any() )->method( 'providerChangeAuthenticationData' ) + ->will( $this->returnCallback( function ( $req ) use ( &$done ) { + $done[$req->id] = true; + } ) ); + $config = new \HashConfig( [ + 'AuthManagerConfig' => [ + 'preauth' => [], + 'primaryauth' => [], + 'secondaryauth' => [ + [ 'factory' => function () use ( $provider ) { + return $provider; + } ], + ], + ], + ] ); + $request = new \FauxRequest(); + $manager = new AuthManager( $request, $config ); + $provider->setManager( $manager ); + $provider = TestingAccessWrapper::newFromObject( $provider ); + + $req = new ConfirmLinkAuthenticationRequest( $reqs ); + + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [], + ] ); + $this->assertEquals( + AuthenticationResponse::newAbstain(), + $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs + ] ); + $this->assertEquals( + AuthenticationResponse::newPass(), + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ) + ); + $this->assertSame( [ false, false, false ], $done ); + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => [ $reqs['Request2'] ], + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request2' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ true, true, false ], $done ); + $done = [ false, false, false ]; + + $request->getSession()->setSecret( 'state', [ + 'maybeLink' => $reqs, + ] ); + $req->confirmedLinkIDs = [ 'Request1', 'Request3' ]; + $res = $provider->continueLinkAttempt( $user, 'state', [ $req ] ); + $this->assertEquals( AuthenticationResponse::UI, $res->status ); + $this->assertCount( 1, $res->neededRequests ); + $this->assertInstanceOf( ButtonAuthenticationRequest::class, $res->neededRequests[0] ); + $this->assertSame( [ true, false, false ], $done ); + $done = [ false, false, false ]; + + $res = $provider->continueLinkAttempt( $user, 'state', [ $res->neededRequests[0] ] ); + $this->assertEquals( AuthenticationResponse::newPass(), $res ); + $this->assertSame( [ false, false, false ], $done ); + } + +} diff --git a/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php new file mode 100644 index 0000000000..ff22def7f8 --- /dev/null +++ b/tests/phpunit/includes/auth/EmailNotificationSecondaryAuthenticationProviderTest.php @@ -0,0 +1,112 @@ + true, + 'EmailAuthentication' => true, + ] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider(); + $provider->setConfig( $config ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertTrue( $providerPriv->sendConfirmationEmail ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setConfig( $config ); + $providerPriv = TestingAccessWrapper::newFromObject( $provider ); + $this->assertFalse( $providerPriv->sendConfirmationEmail ); + } + + /** + * @dataProvider provideGetAuthenticationRequests + * @param string $action + * @param AuthenticationRequest[] $expected + */ + public function testGetAuthenticationRequests( $action, $expected ) { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertSame( $expected, $provider->getAuthenticationRequests( $action, [] ) ); + } + + public function provideGetAuthenticationRequests() { + return [ + [ AuthManager::ACTION_LOGIN, [] ], + [ AuthManager::ACTION_CREATE, [] ], + [ AuthManager::ACTION_LINK, [] ], + [ AuthManager::ACTION_CHANGE, [] ], + [ AuthManager::ACTION_REMOVE, [] ], + ]; + } + + public function testBeginSecondaryAuthentication() { + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $this->assertEquals( AuthenticationResponse::newAbstain(), + $provider->beginSecondaryAuthentication( \User::newFromName( 'Foo' ), [] ) ); + } + + public function testBeginSecondaryAccountCreation() { + $authManager = new AuthManager( new \FauxRequest(), new \HashConfig() ); + + $creator = $this->getMockBuilder( \User::class )->getMock(); + $userWithoutEmail = $this->getMockBuilder( \User::class )->getMock(); + $userWithoutEmail->expects( $this->any() )->method( 'getEmail' )->willReturn( '' ); + $userWithoutEmail->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithoutEmail->expects( $this->never() )->method( 'sendConfirmationMail' ); + $userWithEmailError = $this->getMockBuilder( \User::class )->getMock(); + $userWithEmailError->expects( $this->any() )->method( 'getEmail' )->willReturn( 'foo@bar.baz' ); + $userWithEmailError->expects( $this->any() )->method( 'getInstanceForUpdate' )->willReturnSelf(); + $userWithEmailError->expects( $this->any() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newFatal( 'fail' ) ); + $userExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock(); + $userExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userExpectsConfirmation->expects( $this->once() )->method( 'sendConfirmationMail' ) + ->willReturn( \Status::newGood() ); + $userNotExpectsConfirmation = $this->getMockBuilder( \User::class )->getMock(); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getEmail' ) + ->willReturn( 'foo@bar.baz' ); + $userNotExpectsConfirmation->expects( $this->any() )->method( 'getInstanceForUpdate' ) + ->willReturnSelf(); + $userNotExpectsConfirmation->expects( $this->never() )->method( 'sendConfirmationMail' ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => false, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + + $provider = new EmailNotificationSecondaryAuthenticationProvider( [ + 'sendConfirmationEmail' => true, + ] ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userWithoutEmail, $creator, [] ); + $provider->beginSecondaryAccountCreation( $userExpectsConfirmation, $creator, [] ); + + // test logging of email errors + $logger = $this->getMockForAbstractClass( LoggerInterface::class ); + $logger->expects( $this->once() )->method( 'warning' ); + $provider->setLogger( $logger ); + $provider->beginSecondaryAccountCreation( $userWithEmailError, $creator, [] ); + + // test disable flag used by other providers + $authManager->setAuthenticationSessionData( 'no-email', true ); + $provider->setManager( $authManager ); + $provider->beginSecondaryAccountCreation( $userNotExpectsConfirmation, $creator, [] ); + } +} diff --git a/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php b/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php new file mode 100644 index 0000000000..6190516e68 --- /dev/null +++ b/tests/phpunit/includes/changes/ChangesListFilterGroupTest.php @@ -0,0 +1,79 @@ + 'some_type', + 'name' => 'group_name', + 'priority' => 1, + 'filters' => [], + ] + ); + } + + public function testAutoPriorities() { + $group = new MockChangesListFilterGroup( + [ + 'type' => 'some_type', + 'name' => 'groupName', + 'isFullCoverage' => true, + 'priority' => 1, + 'filters' => [ + [ 'name' => 'hidefoo' ], + [ 'name' => 'hidebar' ], + [ 'name' => 'hidebaz' ], + ], + ] + ); + + $filters = $group->getFilters(); + $this->assertEquals( + [ + -2, + -3, + -4, + ], + array_map( + function ( $f ) { + return $f->getPriority(); + }, + array_values( $filters ) + ) + ); + } + + // Get without warnings + public function testGetFilter() { + $group = new MockChangesListFilterGroup( + [ + 'type' => 'some_type', + 'name' => 'groupName', + 'isFullCoverage' => true, + 'priority' => 1, + 'filters' => [ + [ 'name' => 'foo' ], + ], + ] + ); + + $this->assertEquals( + 'foo', + $group->getFilter( 'foo' )->getName() + ); + + $this->assertEquals( + null, + $group->getFilter( 'bar' ) + ); + } +} diff --git a/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php b/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php new file mode 100644 index 0000000000..417b468b1d --- /dev/null +++ b/tests/phpunit/includes/collation/CustomUppercaseCollationTest.php @@ -0,0 +1,68 @@ +collation = new CustomUppercaseCollation( [ + 'D', + 'C', + 'Cs', + 'B' + ], Language::factory( 'en' ) ); + + parent::setUp(); + } + + /** + * @dataProvider providerOrder + */ + public function testOrder( $first, $second, $msg ) { + $sortkey1 = $this->collation->getSortKey( $first ); + $sortkey2 = $this->collation->getSortKey( $second ); + + $this->assertTrue( strcmp( $sortkey1, $sortkey2 ) < 0, $msg ); + } + + public function providerOrder() { + return [ + [ 'X', 'Z', 'Maintain order of unrearranged' ], + [ 'D', 'C', 'Actually resorts' ], + [ 'D', 'B', 'resort test 2' ], + [ 'Adobe', 'Abode', 'not first letter' ], + [ '💩 ', 'C', 'Test relocated to end' ], + [ 'c', 'b', 'lowercase' ], + [ 'x', 'z', 'lowercase original' ], + [ 'Cz', 'Cs', 'digraphs' ], + [ 'C50D', 'C100', 'Numbers' ] + ]; + } + + /** + * @dataProvider provideGetFirstLetter + */ + public function testGetFirstLetter( $string, $first ) { + $this->assertSame( $this->collation->getFirstLetter( $string ), $first ); + } + + public function provideGetFirstLetter() { + return [ + [ 'Do', 'D' ], + [ 'do', 'D' ], + [ 'Ao', 'A' ], + [ 'afdsa', 'A' ], + [ "\u{F3000}Foo", 'D' ], + [ "\u{F3001}Foo", 'C' ], + [ "\u{F3002}Foo", 'Cs' ], + [ "\u{F3003}Foo", 'B' ], + [ "\u{F3004}Foo", "\u{F3004}" ], + [ 'C', 'C' ], + [ 'Cz', 'C' ], + [ 'Cs', 'Cs' ], + [ 'CS', 'Cs' ], + [ 'cs', 'Cs' ], + ]; + } +} diff --git a/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php new file mode 100644 index 0000000000..c5c0dc7d6b --- /dev/null +++ b/tests/phpunit/includes/composer/ComposerVersionNormalizerTest.php @@ -0,0 +1,163 @@ + + */ +class ComposerVersionNormalizerTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + use PHPUnit4And6Compat; + + /** + * @dataProvider nonStringProvider + */ + public function testGivenNonString_normalizeThrowsInvalidArgumentException( $nonString ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->setExpectedException( InvalidArgumentException::class ); + $normalizer->normalizeSuffix( $nonString ); + } + + public function nonStringProvider() { + return [ + [ null ], + [ 42 ], + [ [] ], + [ new stdClass() ], + [ true ], + ]; + } + + /** + * @dataProvider simpleVersionProvider + */ + public function testGivenSimpleVersion_normalizeSuffixReturnsAsIs( $simpleVersion ) { + $this->assertRemainsUnchanged( $simpleVersion ); + } + + protected function assertRemainsUnchanged( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeSuffix( $version ) + ); + } + + public function simpleVersionProvider() { + return [ + [ '1.22.0' ], + [ '1.19.2' ], + [ '1.19.2.0' ], + [ '1.9' ], + [ '123.321.456.654' ], + ]; + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithoutDash_normalizeSuffixAddsDash( + $withoutDash, $withDash + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $withDash, + $normalizer->normalizeSuffix( $withoutDash ) + ); + } + + public function complexVersionProvider() { + return [ + [ '1.22.0alpha', '1.22.0-alpha' ], + [ '1.22.0RC', '1.22.0-RC' ], + [ '1.19beta', '1.19-beta' ], + [ '1.9RC4', '1.9-RC4' ], + [ '1.9.1.2RC4', '1.9.1.2-RC4' ], + [ '1.9.1.2RC', '1.9.1.2-RC' ], + [ '123.321.456.654RC9001', '123.321.456.654-RC9001' ], + ]; + } + + /** + * @dataProvider complexVersionProvider + */ + public function testGivenComplexVersionWithDash_normalizeSuffixReturnsAsIs( + $withoutDash, $withDash + ) { + $this->assertRemainsUnchanged( $withDash ); + } + + /** + * @dataProvider fourLevelVersionsProvider + */ + public function testGivenFourLevels_levelCountNormalizationDoesNothing( $version ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $version, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function fourLevelVersionsProvider() { + return [ + [ '1.22.0.0' ], + [ '1.19.2.4' ], + [ '1.19.2.0' ], + [ '1.9.0.1' ], + [ '123.321.456.654' ], + [ '123.321.456.654RC4' ], + [ '123.321.456.654-RC4' ], + ]; + } + + /** + * @dataProvider levelNormalizationProvider + */ + public function testGivenFewerLevels_levelCountNormalizationEnsuresFourLevels( + $expected, $version + ) { + $normalizer = new ComposerVersionNormalizer(); + + $this->assertEquals( + $expected, + $normalizer->normalizeLevelCount( $version ) + ); + } + + public function levelNormalizationProvider() { + return [ + [ '1.22.0.0', '1.22' ], + [ '1.22.0.0', '1.22.0' ], + [ '1.19.2.0', '1.19.2' ], + [ '12345.0.0.0', '12345' ], + [ '12345.0.0.0-RC4', '12345-RC4' ], + [ '12345.0.0.0-alpha', '12345-alpha' ], + ]; + } + + /** + * @dataProvider invalidVersionProvider + */ + public function testGivenInvalidVersion_normalizeSuffixReturnsAsIs( $invalidVersion ) { + $this->assertRemainsUnchanged( $invalidVersion ); + } + + public function invalidVersionProvider() { + return [ + [ '1.221-a' ], + [ '1.221-' ], + [ '1.22rc4a' ], + [ 'a1.22rc' ], + [ '.1.22rc' ], + [ 'a' ], + [ 'alpha42' ], + ]; + } +} diff --git a/tests/phpunit/includes/config/ConfigFactoryTest.php b/tests/phpunit/includes/config/ConfigFactoryTest.php new file mode 100644 index 0000000000..ea747afac1 --- /dev/null +++ b/tests/phpunit/includes/config/ConfigFactoryTest.php @@ -0,0 +1,168 @@ +register( 'unittest', 'GlobalVarConfig::newInstance' ); + $this->assertInstanceOf( GlobalVarConfig::class, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInvalid() { + $factory = new ConfigFactory(); + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalid', 'Invalid callback' ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInvalidInstance() { + $factory = new ConfigFactory(); + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalidInstance', new stdClass ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterInstance() { + $config = GlobalVarConfig::newInstance(); + $factory = new ConfigFactory(); + $factory->register( 'unittest', $config ); + $this->assertSame( $config, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::register + */ + public function testRegisterAgain() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $config1 = $factory->makeConfig( 'unittest' ); + + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + $config2 = $factory->makeConfig( 'unittest' ); + + $this->assertNotSame( $config1, $config2 ); + } + + /** + * @covers ConfigFactory::salvage + */ + public function testSalvage() { + $oldFactory = new ConfigFactory(); + $oldFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'bar', 'GlobalVarConfig::newInstance' ); + $oldFactory->register( 'quux', 'GlobalVarConfig::newInstance' ); + + // instantiate two of the three defined configurations + $foo = $oldFactory->makeConfig( 'foo' ); + $bar = $oldFactory->makeConfig( 'bar' ); + $quux = $oldFactory->makeConfig( 'quux' ); + + // define new config instance + $newFactory = new ConfigFactory(); + $newFactory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $newFactory->register( 'bar', function () { + return new HashConfig(); + } ); + + // "foo" and "quux" are defined in the old and the new factory. + // The old factory has instances for "foo" and "bar", but not "quux". + $newFactory->salvage( $oldFactory ); + + $newFoo = $newFactory->makeConfig( 'foo' ); + $this->assertSame( $foo, $newFoo, 'existing instance should be salvaged' ); + + $newBar = $newFactory->makeConfig( 'bar' ); + $this->assertNotSame( $bar, $newBar, 'don\'t salvage if callbacks differ' ); + + // the new factory doesn't have quux defined, so the quux instance should not be salvaged + $this->setExpectedException( ConfigException::class ); + $newFactory->makeConfig( 'quux' ); + } + + /** + * @covers ConfigFactory::getConfigNames + */ + public function testGetConfigNames() { + $factory = new ConfigFactory(); + $factory->register( 'foo', 'GlobalVarConfig::newInstance' ); + $factory->register( 'bar', new HashConfig() ); + + $this->assertEquals( [ 'foo', 'bar' ], $factory->getConfigNames() ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', 'GlobalVarConfig::newInstance' ); + + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( Config::class, $conf ); + $this->assertSame( $conf, $factory->makeConfig( 'unittest' ) ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithObject() { + $factory = new ConfigFactory(); + $conf = new HashConfig(); + $factory->register( 'test', $conf ); + $this->assertSame( $conf, $factory->makeConfig( 'test' ) ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigFallback() { + $factory = new ConfigFactory(); + $factory->register( '*', 'GlobalVarConfig::newInstance' ); + $conf = $factory->makeConfig( 'unittest' ); + $this->assertInstanceOf( Config::class, $conf ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithNoBuilders() { + $factory = new ConfigFactory(); + $this->setExpectedException( ConfigException::class ); + $factory->makeConfig( 'nobuilderregistered' ); + } + + /** + * @covers ConfigFactory::makeConfig + */ + public function testMakeConfigWithInvalidCallback() { + $factory = new ConfigFactory(); + $factory->register( 'unittest', function () { + return true; // Not a Config object + } ); + $this->setExpectedException( UnexpectedValueException::class ); + $factory->makeConfig( 'unittest' ); + } + + /** + * @covers ConfigFactory::getDefaultInstance + */ + public function testGetDefaultInstance() { + // NOTE: the global config factory returned here has been overwritten + // for operation in test mode. It may not reflect LocalSettings. + $factory = MediaWikiServices::getInstance()->getConfigFactory(); + $this->assertInstanceOf( Config::class, $factory->makeConfig( 'main' ) ); + } + +} diff --git a/tests/phpunit/includes/config/EtcdConfigTest.php b/tests/phpunit/includes/config/EtcdConfigTest.php new file mode 100644 index 0000000000..3eecf82704 --- /dev/null +++ b/tests/phpunit/includes/config/EtcdConfigTest.php @@ -0,0 +1,621 @@ +getMockBuilder( EtcdConfig::class ) + ->setConstructorArgs( [ $options + [ + 'host' => 'etcd-tcp.example.net', + 'directory' => '/', + 'timeout' => 0.1, + ] ] ) + ->setMethods( [ 'fetchAllFromEtcd' ] ) + ->getMock(); + } + + private static function createEtcdResponse( array $response ) { + $baseResponse = [ + 'config' => null, + 'error' => null, + 'retry' => false, + 'modifiedIndex' => 0, + ]; + return array_merge( $baseResponse, $response ); + } + + private function createSimpleConfigMock( array $config, $index = 0 ) { + $mock = $this->createConfigMock(); + $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( self::createEtcdResponse( [ + 'config' => $config, + 'modifiedIndex' => $index, + ] ) ); + return $mock; + } + + /** + * @covers EtcdConfig::has + */ + public function testHasKnown() { + $config = $this->createSimpleConfigMock( [ + 'known' => 'value' + ] ); + $this->assertSame( true, $config->has( 'known' ) ); + } + + /** + * @covers EtcdConfig::__construct + * @covers EtcdConfig::get + */ + public function testGetKnown() { + $config = $this->createSimpleConfigMock( [ + 'known' => 'value' + ] ); + $this->assertSame( 'value', $config->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::has + */ + public function testHasUnknown() { + $config = $this->createSimpleConfigMock( [ + 'known' => 'value' + ] ); + $this->assertSame( false, $config->has( 'unknown' ) ); + } + + /** + * @covers EtcdConfig::get + */ + public function testGetUnknown() { + $config = $this->createSimpleConfigMock( [ + 'known' => 'value' + ] ); + $this->setExpectedException( ConfigException::class ); + $config->get( 'unknown' ); + } + + /** + * @covers EtcdConfig::getModifiedIndex + */ + public function testGetModifiedIndex() { + $config = $this->createSimpleConfigMock( + [ 'some' => 'value' ], + 123 + ); + $this->assertSame( 123, $config->getModifiedIndex() ); + } + + /** + * @covers EtcdConfig::__construct + */ + public function testConstructCacheObj() { + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' ) + ->willReturn( [ + 'config' => [ 'known' => 'from-cache' ], + 'expires' => INF, + 'modifiedIndex' => 123 + ] ); + $config = $this->createConfigMock( [ 'cache' => $cache ] ); + + $this->assertSame( 'from-cache', $config->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::__construct + */ + public function testConstructCacheSpec() { + $config = $this->createConfigMock( [ 'cache' => [ + 'class' => HashBagOStuff::class + ] ] ); + $config->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( self::createEtcdResponse( + [ 'config' => [ 'known' => 'from-fetch' ], ] ) ); + + $this->assertSame( 'from-fetch', $config->get( 'known' ) ); + } + + /** + * Test matrix + * + * - [x] Cache miss + * Result: Fetched value + * > cache miss | gets lock | backend succeeds + * + * - [x] Cache miss with backend error + * Result: ConfigException + * > cache miss | gets lock | backend error (no retry) + * + * - [x] Cache hit after retry + * Result: Cached value (populated by process holding lock) + * > cache miss | no lock | cache retry + * + * - [x] Cache hit + * Result: Cached value + * > cache hit + * + * - [x] Process cache hit + * Result: Cached value + * > process cache hit + * + * - [x] Cache expired + * Result: Fetched value + * > cache expired | gets lock | backend succeeds + * + * - [x] Cache expired with backend failure + * Result: Cached value (stale) + * > cache expired | gets lock | backend fails (allows retry) + * + * - [x] Cache expired and no lock + * Result: Cached value (stale) + * > cache expired | no lock + * + * Other notable scenarios: + * + * - [ ] Cache miss with backend retry + * Result: Fetched value + * > cache expired | gets lock | backend failure (allows retry) + */ + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheMiss() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + // .. misses cache + $cache->expects( $this->once() )->method( 'get' ) + ->willReturn( false ); + // .. gets lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( true ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( + self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); + + $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheMissBackendError() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + // .. misses cache + $cache->expects( $this->once() )->method( 'get' ) + ->willReturn( false ); + // .. gets lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( true ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) ); + + $this->setExpectedException( ConfigException::class ); + $mock->get( 'key' ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheMissWithoutLock() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->exactly( 2 ) )->method( 'get' ) + ->will( $this->onConsecutiveCalls( + // .. misses cache first time + false, + // .. hits cache on retry + [ + 'config' => [ 'known' => 'from-cache' ], + 'expires' => INF, + 'modifiedIndex' => 123 + ] + ) ); + // .. misses lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( false ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); + + $this->assertSame( 'from-cache', $mock->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheHit() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' ) + // .. hits cache + ->willReturn( [ + 'config' => [ 'known' => 'from-cache' ], + 'expires' => INF, + 'modifiedIndex' => 0, + ] ); + $cache->expects( $this->never() )->method( 'lock' ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); + + $this->assertSame( 'from-cache', $mock->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadProcessCacheHit() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' ) + // .. hits cache + ->willReturn( [ + 'config' => [ 'known' => 'from-cache' ], + 'expires' => INF, + 'modifiedIndex' => 0, + ] ); + $cache->expects( $this->never() )->method( 'lock' ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); + + $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' ); + $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheExpiredLockFetchSucceeded() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' )->willReturn( + // .. stale cache + [ + 'config' => [ 'known' => 'from-cache-expired' ], + 'expires' => -INF, + 'modifiedIndex' => 0, + ] + ); + // .. gets lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( true ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) ); + + $this->assertSame( 'from-fetch', $mock->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheExpiredLockFetchFails() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' )->willReturn( + // .. stale cache + [ + 'config' => [ 'known' => 'from-cache-expired' ], + 'expires' => -INF, + 'modifiedIndex' => 0, + ] + ); + // .. gets lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( true ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' ) + ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) ); + + $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) ); + } + + /** + * @covers EtcdConfig::load + */ + public function testLoadCacheExpiredNoLock() { + // Create cache mock + $cache = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'get', 'lock' ] ) + ->getMock(); + $cache->expects( $this->once() )->method( 'get' ) + // .. hits cache (expired value) + ->willReturn( [ + 'config' => [ 'known' => 'from-cache-expired' ], + 'expires' => -INF, + 'modifiedIndex' => 0, + ] ); + // .. misses lock + $cache->expects( $this->once() )->method( 'lock' ) + ->willReturn( false ); + + // Create config mock + $mock = $this->createConfigMock( [ + 'cache' => $cache, + ] ); + $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' ); + + $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) ); + } + + public static function provideFetchFromServer() { + return [ + '200 OK - Success' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/foo', + 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123 + ], + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'foo' => true ], // data + 'modifiedIndex' => 123 + ] ), + ], + '200 OK - Empty dir' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/foo', + 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123 + ], + [ + 'key' => '/example/sub', + 'dir' => true, + 'modifiedIndex' => 234, + 'nodes' => [], + ], + [ + 'key' => '/example/bar', + 'value' => json_encode( [ 'val' => false ] ), + 'modifiedIndex' => 125 + ], + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'foo' => true, 'bar' => false ], // data + 'modifiedIndex' => 125 // largest modified index + ] ), + ], + '200 OK - Recursive' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/a', + 'dir' => true, + 'modifiedIndex' => 124, + 'nodes' => [ + [ + 'key' => 'b', + 'value' => json_encode( [ 'val' => true ] ), + 'modifiedIndex' => 123, + + ], + [ + 'key' => 'c', + 'value' => json_encode( [ 'val' => false ] ), + 'modifiedIndex' => 123, + ], + ], + ], + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'config' => [ 'a/b' => true, 'a/c' => false ], // data + 'modifiedIndex' => 123 // largest modified index + ] ), + ], + '200 OK - Missing nodes at second level' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/a', + 'dir' => true, + 'modifiedIndex' => 0, + ], + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.", + ] ), + ], + '200 OK - Directory with non-array "nodes" key' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/a', + 'dir' => true, + 'nodes' => 'not an array' + ], + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.", + ] ), + ], + '200 OK - Correctly encoded garbage response' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'foo' => 'bar' ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Unexpected JSON response: Missing or invalid node at top level.", + ] ), + ], + '200 OK - Bad value' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => json_encode( [ 'node' => [ 'nodes' => [ + [ + 'key' => '/example/foo', + 'value' => ';"broken{value', + 'modifiedIndex' => 123, + ] + ] ] ] ), + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Failed to parse value for 'foo'.", + ] ), + ], + '200 OK - Empty node list' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [], + 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}', + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'config' => [], // data + ] ), + ], + '200 OK - Invalid JSON' => [ + 'http' => [ + 'code' => 200, + 'reason' => 'OK', + 'headers' => [ 'content-length' => 0 ], + 'body' => '', + 'error' => '(curl error: no status set)', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => "Error unserializing JSON response.", + ] ), + ], + '404 Not Found' => [ + 'http' => [ + 'code' => 404, + 'reason' => 'Not Found', + 'headers' => [ 'content-length' => 0 ], + 'body' => '', + 'error' => '', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => 'HTTP 404 (Not Found)', + ] ), + ], + '400 Bad Request - custom error' => [ + 'http' => [ + 'code' => 400, + 'reason' => 'Bad Request', + 'headers' => [ 'content-length' => 0 ], + 'body' => '', + 'error' => 'No good reason', + ], + 'expect' => self::createEtcdResponse( [ + 'error' => 'No good reason', + 'retry' => true, // retry + ] ), + ], + ]; + } + + /** + * @covers EtcdConfig::fetchAllFromEtcdServer + * @covers EtcdConfig::unserialize + * @covers EtcdConfig::parseResponse + * @covers EtcdConfig::parseDirectory + * @covers EtcdConfigParseError + * @dataProvider provideFetchFromServer + */ + public function testFetchFromServer( array $httpResponse, array $expected ) { + $http = $this->getMockBuilder( MultiHttpClient::class ) + ->disableOriginalConstructor() + ->getMock(); + $http->expects( $this->once() )->method( 'run' ) + ->willReturn( array_values( $httpResponse ) ); + + $conf = $this->getMockBuilder( EtcdConfig::class ) + ->disableOriginalConstructor() + ->getMock(); + // Access for protected member and method + $conf = TestingAccessWrapper::newFromObject( $conf ); + $conf->http = $http; + + $this->assertSame( + $expected, + $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' ) + ); + } +} diff --git a/tests/phpunit/includes/config/HashConfigTest.php b/tests/phpunit/includes/config/HashConfigTest.php new file mode 100644 index 0000000000..bac8311cd4 --- /dev/null +++ b/tests/phpunit/includes/config/HashConfigTest.php @@ -0,0 +1,63 @@ +assertInstanceOf( HashConfig::class, $conf ); + } + + /** + * @covers HashConfig::__construct + */ + public function testConstructor() { + $conf = new HashConfig(); + $this->assertInstanceOf( HashConfig::class, $conf ); + + // Test passing arguments to the constructor + $conf2 = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertEquals( '1', $conf2->get( 'one' ) ); + } + + /** + * @covers HashConfig::get + */ + public function testGet() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertEquals( '1', $conf->get( 'one' ) ); + $this->setExpectedException( ConfigException::class, 'HashConfig::get: undefined option' ); + $conf->get( 'two' ); + } + + /** + * @covers HashConfig::has + */ + public function testHas() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $this->assertTrue( $conf->has( 'one' ) ); + $this->assertFalse( $conf->has( 'two' ) ); + } + + /** + * @covers HashConfig::set + */ + public function testSet() { + $conf = new HashConfig( [ + 'one' => '1', + ] ); + $conf->set( 'two', '2' ); + $this->assertEquals( '2', $conf->get( 'two' ) ); + // Check that set overwrites + $conf->set( 'one', '3' ); + $this->assertEquals( '3', $conf->get( 'one' ) ); + } +} diff --git a/tests/phpunit/includes/config/MultiConfigTest.php b/tests/phpunit/includes/config/MultiConfigTest.php new file mode 100644 index 0000000000..fc2839513b --- /dev/null +++ b/tests/phpunit/includes/config/MultiConfigTest.php @@ -0,0 +1,39 @@ + 'bar' ] ), + new HashConfig( [ 'foo' => 'baz', 'bar' => 'foo' ] ), + new HashConfig( [ 'bar' => 'baz' ] ), + ] ); + + $this->assertEquals( 'bar', $multi->get( 'foo' ) ); + $this->assertEquals( 'foo', $multi->get( 'bar' ) ); + $this->setExpectedException( ConfigException::class, 'MultiConfig::get: undefined option:' ); + $multi->get( 'notset' ); + } + + /** + * @covers MultiConfig::has + */ + public function testHas() { + $conf = new MultiConfig( [ + new HashConfig( [ 'foo' => 'foo' ] ), + new HashConfig( [ 'something' => 'bleh' ] ), + new HashConfig( [ 'meh' => 'eh' ] ), + ] ); + + $this->assertTrue( $conf->has( 'foo' ) ); + $this->assertTrue( $conf->has( 'something' ) ); + $this->assertTrue( $conf->has( 'meh' ) ); + $this->assertFalse( $conf->has( 'what' ) ); + } +} diff --git a/tests/phpunit/includes/config/ServiceOptionsTest.php b/tests/phpunit/includes/config/ServiceOptionsTest.php new file mode 100644 index 0000000000..966cf411c7 --- /dev/null +++ b/tests/phpunit/includes/config/ServiceOptionsTest.php @@ -0,0 +1,149 @@ + $val ) { + $this->assertSame( $val, $options->get( $key ) ); + } + + // This is lumped in the same test because there's no support for depending on a test that + // has a data provider. + $options->assertRequiredOptions( array_keys( $expected ) ); + + // Suppress warning if no assertions were run. This is expected for empty arguments. + $this->assertTrue( true ); + } + + public function provideConstructor() { + return [ + 'No keys' => [ [], [], [ 'a' => 'aval' ] ], + 'Simple array source' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ], + ], + 'Simple HashConfig source' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + new HashConfig( [ 'a' => 'aval', 'b' => 'bval', 'c' => 'cval' ] ), + ], + 'Three different sources' => [ + [ 'a' => 'aval', 'b' => 'bval' ], + [ 'a', 'b' ], + [ 'z' => 'zval' ], + new HashConfig( [ 'a' => 'aval', 'c' => 'cval' ] ), + [ 'b' => 'bval', 'd' => 'dval' ], + ], + 'null key' => [ + [ 'a' => null ], + [ 'a' ], + [ 'a' => null ], + ], + 'Numeric option name' => [ + [ '0' => 'nothing' ], + [ '0' ], + [ '0' => 'nothing' ], + ], + 'Multiple sources for one key' => [ + [ 'a' => 'winner' ], + [ 'a' ], + [ 'a' => 'winner' ], + [ 'a' => 'second place' ], + ], + 'Object value is passed by reference' => [ + [ 'a' => self::$testObj ], + [ 'a' ], + [ 'a' => self::$testObj ], + ], + ]; + } + + /** + * @covers ::__construct + */ + public function testKeyNotFound() { + $this->setExpectedException( InvalidArgumentException::class, + 'Key "a" not found in input sources' ); + + new ServiceOptions( [ 'a' ], [ 'b' => 'bval' ], [ 'c' => 'cval' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testOutOfOrderAssertRequiredOptions() { + $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); + $options->assertRequiredOptions( [ 'b', 'a' ] ); + $this->assertTrue( true, 'No exception thrown' ); + } + + /** + * @covers ::__construct + * @covers ::get + */ + public function testGetUnrecognized() { + $this->setExpectedException( InvalidArgumentException::class, + 'Unrecognized option "b"' ); + + $options = new ServiceOptions( [ 'a' ], [ 'a' => '' ] ); + $options->get( 'b' ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testExtraKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Unsupported options passed: b, c!' ); + + $options = new ServiceOptions( [ 'a', 'b', 'c' ], [ 'a' => '', 'b' => '', 'c' => '' ] ); + $options->assertRequiredOptions( [ 'a' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testMissingKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Required options missing: a, b!' ); + + $options = new ServiceOptions( [ 'c' ], [ 'c' => '' ] ); + $options->assertRequiredOptions( [ 'a', 'b', 'c' ] ); + } + + /** + * @covers ::__construct + * @covers ::assertRequiredOptions + */ + public function testExtraAndMissingKeys() { + $this->setExpectedException( Wikimedia\Assert\PreconditionException::class, + 'Precondition failed: Unsupported options passed: b! Required options missing: c!' ); + + $options = new ServiceOptions( [ 'a', 'b' ], [ 'a' => '', 'b' => '' ] ); + $options->assertRequiredOptions( [ 'a', 'c' ] ); + } +} diff --git a/tests/phpunit/includes/content/JsonContentHandlerTest.php b/tests/phpunit/includes/content/JsonContentHandlerTest.php new file mode 100644 index 0000000000..abfb6733a5 --- /dev/null +++ b/tests/phpunit/includes/content/JsonContentHandlerTest.php @@ -0,0 +1,14 @@ +makeEmptyContent(); + $this->assertInstanceOf( JsonContent::class, $content ); + $this->assertTrue( $content->isValid() ); + } +} diff --git a/tests/phpunit/includes/db/DatabaseOracleTest.php b/tests/phpunit/includes/db/DatabaseOracleTest.php new file mode 100644 index 0000000000..061e121a24 --- /dev/null +++ b/tests/phpunit/includes/db/DatabaseOracleTest.php @@ -0,0 +1,52 @@ +getMockBuilder( DatabaseOracle::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ]; + } + + /** + * @covers DatabaseOracle::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 DatabaseOracle::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/tests/phpunit/includes/debug/MWDebugTest.php b/tests/phpunit/includes/debug/MWDebugTest.php new file mode 100644 index 0000000000..6f0b1db9f2 --- /dev/null +++ b/tests/phpunit/includes/debug/MWDebugTest.php @@ -0,0 +1,140 @@ +assertEquals( + [ [ + 'msg' => 'logging a string', + 'type' => 'log', + 'caller' => 'MWDebugTest->testAddLog', + ] ], + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::warning + */ + public function testAddWarning() { + MWDebug::warning( 'Warning message' ); + $this->assertEquals( + [ [ + 'msg' => 'Warning message', + 'type' => 'warn', + 'caller' => 'MWDebugTest::testAddWarning', + ] ], + MWDebug::getLog() + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 1, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::deprecated + */ + public function testAvoidNonConsecutivesDuplicateDeprecations() { + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + MWDebug::warning( 'some warning' ); + MWDebug::log( 'we could have logged something too' ); + // Another deprecation + MWDebug::deprecated( 'wfOldFunction', '1.0', 'component' ); + + // assertCount() not available on WMF integration server + $this->assertEquals( 3, + count( MWDebug::getLog() ), + "Only one deprecated warning per function should be kept" + ); + } + + /** + * @covers MWDebug::appendDebugInfoToApiResult + */ + public function testAppendDebugInfoToApiResultXmlFormat() { + $request = $this->newApiRequest( + [ 'action' => 'help', 'format' => 'xml' ], + '/api.php?action=help&format=xml' + ); + + $context = new RequestContext(); + $context->setRequest( $request ); + + $apiMain = new ApiMain( $context ); + + $result = new ApiResult( $apiMain ); + + MWDebug::appendDebugInfoToApiResult( $context, $result ); + + $this->assertInstanceOf( ApiResult::class, $result ); + $data = $result->getResultData(); + + $expectedKeys = [ 'mwVersion', 'phpEngine', 'phpVersion', 'gitRevision', 'gitBranch', + 'gitViewUrl', 'time', 'log', 'debugLog', 'queries', 'request', 'memory', + 'memoryPeak', 'includes', '_element' ]; + + foreach ( $expectedKeys as $expectedKey ) { + $this->assertArrayHasKey( $expectedKey, $data['debuginfo'], "debuginfo has $expectedKey" ); + } + + $xml = ApiFormatXml::recXmlPrint( 'help', $data, null ); + + // exception not thrown + $this->assertInternalType( 'string', $xml ); + } + + /** + * @param string[] $params + * @param string $requestUrl + * + * @return FauxRequest + */ + private function newApiRequest( array $params, $requestUrl ) { + $request = $this->getMockBuilder( FauxRequest::class ) + ->setMethods( [ 'getRequestURL' ] ) + ->setConstructorArgs( [ + $params + ] ) + ->getMock(); + + $request->expects( $this->any() ) + ->method( 'getRequestURL' ) + ->will( $this->returnValue( $requestUrl ) ); + + return $request; + } + +} diff --git a/tests/phpunit/includes/debug/logger/MonologSpiTest.php b/tests/phpunit/includes/debug/logger/MonologSpiTest.php new file mode 100644 index 0000000000..fda3ac614a --- /dev/null +++ b/tests/phpunit/includes/debug/logger/MonologSpiTest.php @@ -0,0 +1,136 @@ + [ + '@default' => [ + 'processors' => [ 'constructor' ], + 'handlers' => [ 'constructor' ], + ], + ], + 'processors' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + ], + 'handlers' => [ + 'constructor' => [ + 'class' => 'constructor', + 'formatter' => 'constructor', + ], + ], + 'formatters' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + ], + ]; + + $fixture = new MonologSpi( $base ); + $this->assertSame( + $base, + TestingAccessWrapper::newFromObject( $fixture )->config + ); + + $fixture->mergeConfig( [ + 'loggers' => [ + 'merged' => [ + 'processors' => [ 'merged' ], + 'handlers' => [ 'merged' ], + ], + ], + 'processors' => [ + 'merged' => [ + 'class' => 'merged', + ], + ], + 'magic' => [ + 'idkfa' => [ 'xyzzy' ], + ], + 'handlers' => [ + 'merged' => [ + 'class' => 'merged', + 'formatter' => 'merged', + ], + ], + 'formatters' => [ + 'merged' => [ + 'class' => 'merged', + ], + ], + ] ); + $this->assertSame( + [ + 'loggers' => [ + '@default' => [ + 'processors' => [ 'constructor' ], + 'handlers' => [ 'constructor' ], + ], + 'merged' => [ + 'processors' => [ 'merged' ], + 'handlers' => [ 'merged' ], + ], + ], + 'processors' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + ], + ], + 'handlers' => [ + 'constructor' => [ + 'class' => 'constructor', + 'formatter' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + 'formatter' => 'merged', + ], + ], + 'formatters' => [ + 'constructor' => [ + 'class' => 'constructor', + ], + 'merged' => [ + 'class' => 'merged', + ], + ], + 'magic' => [ + 'idkfa' => [ 'xyzzy' ], + ], + ], + TestingAccessWrapper::newFromObject( $fixture )->config + ); + } + +} diff --git a/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php new file mode 100644 index 0000000000..baa4df7390 --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/AvroFormatterTest.php @@ -0,0 +1,76 @@ +markTestSkipped( 'Avro is required for the AvroFormatterTest' ); + } + parent::setUp(); + } + + public function testSchemaNotAvailable() { + $formatter = new AvroFormatter( [] ); + $this->setExpectedException( + 'PHPUnit_Framework_Error_Notice', + "The schema for channel 'marty' is not available" + ); + $formatter->format( [ 'channel' => 'marty' ] ); + } + + public function testSchemaNotAvailableReturnValue() { + $formatter = new AvroFormatter( [] ); + $noticeEnabled = PHPUnit_Framework_Error_Notice::$enabled; + // disable conversion of notices + PHPUnit_Framework_Error_Notice::$enabled = false; + // have to keep the user notice from being output + \Wikimedia\suppressWarnings(); + $res = $formatter->format( [ 'channel' => 'marty' ] ); + \Wikimedia\restoreWarnings(); + PHPUnit_Framework_Error_Notice::$enabled = $noticeEnabled; + $this->assertNull( $res ); + } + + public function testDoesSomethingWhenSchemaAvailable() { + $formatter = new AvroFormatter( [ + 'string' => [ + 'schema' => [ 'type' => 'string' ], + 'revision' => 1010101, + ] + ] ); + $res = $formatter->format( [ + 'channel' => 'string', + 'context' => 'better to be', + ] ); + $this->assertNotNull( $res ); + // basically just tell us if avro changes its string encoding, or if + // we completely fail to generate a log message. + $this->assertEquals( 'AAAAAAAAD2m1GGJldHRlciB0byBiZQ==', base64_encode( $res ) ); + } +} diff --git a/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php new file mode 100644 index 0000000000..b30c7a4c92 --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/CeeFormatterTest.php @@ -0,0 +1,20 @@ + [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ]; + $this->assertSame( + $cee_formatter->format( $record ), + "@cee: " . $ls_formatter->format( $record ) ); + } +} diff --git a/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php new file mode 100644 index 0000000000..4c0ca04fef --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/KafkaHandlerTest.php @@ -0,0 +1,227 @@ +markTestSkipped( 'Monolog and Kafka are required for the KafkaHandlerTest' ); + } + + parent::setUp(); + } + + public function topicNamingProvider() { + return [ + [ [], 'monolog_foo' ], + [ [ 'alias' => [ 'foo' => 'bar' ] ], 'bar' ] + ]; + } + + /** + * @dataProvider topicNamingProvider + */ + public function testTopicNaming( $options, $expect ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->once() ) + ->method( 'setMessages' ) + ->with( $expect, $this->anything(), $this->anything() ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + } + + public function swallowsExceptionsWhenRequested() { + return [ + // defaults to false + [ [], true ], + // also try false explicitly + [ [ 'swallowExceptions' => false ], true ], + // turn it on + [ [ 'swallowExceptions' => true ], false ], + ]; + } + + /** + * @dataProvider swallowsExceptionsWhenRequested + */ + public function testGetAvailablePartitionsException( $options, $expectException ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->throwException( new \Kafka\Exception ) ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + if ( $expectException ) { + $this->setExpectedException( 'Kafka\Exception' ); + } + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + + if ( !$expectException ) { + $this->assertTrue( true, 'no exception was thrown' ); + } + } + + /** + * @dataProvider swallowsExceptionsWhenRequested + */ + public function testSendException( $options, $expectException ) { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->throwException( new \Kafka\Exception ) ); + + if ( $expectException ) { + $this->setExpectedException( 'Kafka\Exception' ); + } + + $handler = new KafkaHandler( $produce, $options ); + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + + if ( !$expectException ) { + $this->assertTrue( true, 'no exception was thrown' ); + } + } + + public function testHandlesNullFormatterResult() { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $mockMethod = $produce->expects( $this->exactly( 2 ) ) + ->method( 'setMessages' ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + // evil hax + $matcher = TestingAccessWrapper::newFromObject( $mockMethod )->matcher; + TestingAccessWrapper::newFromObject( $matcher )->parametersMatcher = + new \PHPUnit_Framework_MockObject_Matcher_ConsecutiveParameters( [ + [ $this->anything(), $this->anything(), [ 'words' ] ], + [ $this->anything(), $this->anything(), [ 'lines' ] ] + ] ); + + $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); + $formatter->expects( $this->any() ) + ->method( 'format' ) + ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); + + $handler = new KafkaHandler( $produce, [] ); + $handler->setFormatter( $formatter ); + for ( $i = 0; $i < 3; ++$i ) { + $handler->handle( [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ] ); + } + } + + public function testBatchHandlesNullFormatterResult() { + $produce = $this->getMockBuilder( 'Kafka\Produce' ) + ->disableOriginalConstructor() + ->getMock(); + $produce->expects( $this->any() ) + ->method( 'getAvailablePartitions' ) + ->will( $this->returnValue( [ 'A' ] ) ); + $produce->expects( $this->once() ) + ->method( 'setMessages' ) + ->with( $this->anything(), $this->anything(), [ 'words', 'lines' ] ); + $produce->expects( $this->any() ) + ->method( 'send' ) + ->will( $this->returnValue( true ) ); + + $formatter = $this->createMock( \Monolog\Formatter\FormatterInterface::class ); + $formatter->expects( $this->any() ) + ->method( 'format' ) + ->will( $this->onConsecutiveCalls( 'words', null, 'lines' ) ); + + $handler = new KafkaHandler( $produce, [] ); + $handler->setFormatter( $formatter ); + $handler->handleBatch( [ + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + [ + 'channel' => 'foo', + 'level' => Logger::EMERGENCY, + 'extra' => [], + 'context' => [], + ], + ] ); + } +} diff --git a/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php new file mode 100644 index 0000000000..bdd5c81118 --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/LineFormatterTest.php @@ -0,0 +1,122 @@ +markTestSkipped( 'This test requires monolog to be installed' ); + } + parent::setUp(); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionNoTrace() { + $fixture = new LineFormatter(); + $fixture->includeStacktraces( false ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new LogicException( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); + $this->assertNotContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionTrace() { + $fixture = new LineFormatter(); + $fixture->includeStacktraces( true ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new LogicException( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Exception LogicException]", $out ); + $this->assertContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionErrorNoTrace() { + if ( !class_exists( AssertionError::class ) ) { + $this->markTestSkipped( 'AssertionError class does not exist' ); + } + + $fixture = new LineFormatter(); + $fixture->includeStacktraces( false ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new AssertionError( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); + $this->assertNotContains( "\n #0", $out ); + } + + /** + * @covers MediaWiki\Logger\Monolog\LineFormatter::normalizeException + */ + public function testNormalizeExceptionErrorTrace() { + if ( !class_exists( AssertionError::class ) ) { + $this->markTestSkipped( 'AssertionError class does not exist' ); + } + + $fixture = new LineFormatter(); + $fixture->includeStacktraces( true ); + $fixture = TestingAccessWrapper::newFromObject( $fixture ); + $boom = new InvalidArgumentException( 'boom', 0, + new LengthException( 'too long', 0, + new AssertionError( 'Spock wuz here' ) + ) + ); + $out = $fixture->normalizeException( $boom ); + $this->assertContains( "\n[Exception InvalidArgumentException]", $out ); + $this->assertContains( "\nCaused by: [Exception LengthException]", $out ); + $this->assertContains( "\nCaused by: [Error AssertionError]", $out ); + $this->assertContains( "\n #0", $out ); + } +} diff --git a/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php b/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php new file mode 100644 index 0000000000..a1207b26b2 --- /dev/null +++ b/tests/phpunit/includes/debug/logger/monolog/LogstashFormatterTest.php @@ -0,0 +1,59 @@ +format( $record ), true ); + foreach ( $expected as $key => $value ) { + $this->assertArrayHasKey( $key, $formatted ); + $this->assertSame( $value, $formatted[$key] ); + } + foreach ( $notExpected as $key ) { + $this->assertArrayNotHasKey( $key, $formatted ); + } + } + + public function provideV1() { + return [ + [ + [ 'extra' => [ 'foo' => 1 ], 'context' => [ 'bar' => 2 ] ], + [ 'foo' => 1, 'bar' => 2 ], + [ 'logstash_formatter_key_conflict' ], + ], + [ + [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ], + [ 'url' => 1, 'c_url' => 2, 'logstash_formatter_key_conflict' => [ 'url' ] ], + [], + ], + [ + [ 'channel' => 'x', 'context' => [ 'channel' => 'y' ] ], + [ 'channel' => 'x', 'c_channel' => 'y', + 'logstash_formatter_key_conflict' => [ 'channel' ] ], + [], + ], + ]; + } + + /** + * @covers MediaWiki\Logger\Monolog\LogstashFormatter::formatV1 + */ + public function testV1WithPrefix() { + $formatter = new LogstashFormatter( 'app', 'system', null, 'ctx_', LogstashFormatter::V1 ); + $record = [ 'extra' => [ 'url' => 1 ], 'context' => [ 'url' => 2 ] ]; + $formatted = json_decode( $formatter->format( $record ), true ); + $this->assertArrayHasKey( 'url', $formatted ); + $this->assertSame( 1, $formatted['url'] ); + $this->assertArrayHasKey( 'ctx_url', $formatted ); + $this->assertSame( 2, $formatted['ctx_url'] ); + $this->assertArrayNotHasKey( 'c_url', $formatted ); + } +} diff --git a/tests/phpunit/includes/deferred/MWCallableUpdateTest.php b/tests/phpunit/includes/deferred/MWCallableUpdateTest.php new file mode 100644 index 0000000000..3ab9b5659e --- /dev/null +++ b/tests/phpunit/includes/deferred/MWCallableUpdateTest.php @@ -0,0 +1,82 @@ +assertSame( 0, $ran ); + $update->doUpdate(); + $this->assertSame( 1, $ran ); + } + + public function testCancel() { + // Prepare update and DB + $db = new DatabaseTestHelper( __METHOD__ ); + $db->begin( __METHOD__ ); + $ran = 0; + $update = new MWCallableUpdate( function () use ( &$ran ) { + $ran++; + }, __METHOD__, $db ); + + // Emulate rollback + $db->rollback( __METHOD__ ); + + $update->doUpdate(); + + // Ensure it was cancelled + $this->assertSame( 0, $ran ); + } + + public function testCancelSome() { + // Prepare update and DB + $db1 = new DatabaseTestHelper( __METHOD__ ); + $db1->begin( __METHOD__ ); + $db2 = new DatabaseTestHelper( __METHOD__ ); + $db2->begin( __METHOD__ ); + $ran = 0; + $update = new MWCallableUpdate( function () use ( &$ran ) { + $ran++; + }, __METHOD__, [ $db1, $db2 ] ); + + // Emulate rollback + $db1->rollback( __METHOD__ ); + + $update->doUpdate(); + + // Prevents: "Notice: DB transaction writes or callbacks still pending" + $db2->rollback( __METHOD__ ); + + // Ensure it was cancelled + $this->assertSame( 0, $ran ); + } + + public function testCancelAll() { + // Prepare update and DB + $db1 = new DatabaseTestHelper( __METHOD__ ); + $db1->begin( __METHOD__ ); + $db2 = new DatabaseTestHelper( __METHOD__ ); + $db2->begin( __METHOD__ ); + $ran = 0; + $update = new MWCallableUpdate( function () use ( &$ran ) { + $ran++; + }, __METHOD__, [ $db1, $db2 ] ); + + // Emulate rollbacks + $db1->rollback( __METHOD__ ); + $db2->rollback( __METHOD__ ); + + $update->doUpdate(); + + // Ensure it was cancelled + $this->assertSame( 0, $ran ); + } + +} diff --git a/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php b/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php new file mode 100644 index 0000000000..693897e676 --- /dev/null +++ b/tests/phpunit/includes/deferred/TransactionRoundDefiningUpdateTest.php @@ -0,0 +1,19 @@ +assertSame( 0, $ran ); + $update->doUpdate(); + $this->assertSame( 1, $ran ); + } +} diff --git a/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php new file mode 100644 index 0000000000..8d94404cd4 --- /dev/null +++ b/tests/phpunit/includes/diff/ArrayDiffFormatterTest.php @@ -0,0 +1,134 @@ +format( $input ); + $this->assertEquals( $expectedOutput, $output ); + } + + private function getMockDiff( $edits ) { + $diff = $this->getMockBuilder( Diff::class ) + ->disableOriginalConstructor() + ->getMock(); + $diff->expects( $this->any() ) + ->method( 'getEdits' ) + ->will( $this->returnValue( $edits ) ); + return $diff; + } + + private function getMockDiffOp( $type = null, $orig = [], $closing = [] ) { + $diffOp = $this->getMockBuilder( DiffOp::class ) + ->disableOriginalConstructor() + ->getMock(); + $diffOp->expects( $this->any() ) + ->method( 'getType' ) + ->will( $this->returnValue( $type ) ); + $diffOp->expects( $this->any() ) + ->method( 'getOrig' ) + ->will( $this->returnValue( $orig ) ); + if ( $type === 'change' ) { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->with( $this->isType( 'integer' ) ) + ->will( $this->returnCallback( function () { + return 'mockLine'; + } ) ); + } else { + $diffOp->expects( $this->any() ) + ->method( 'getClosing' ) + ->will( $this->returnValue( $closing ) ); + } + return $diffOp; + } + + public function provideTestFormat() { + $emptyArrayTestCases = [ + $this->getMockDiff( [] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'add' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'delete' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'change' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'copy' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'FOOBARBAZ' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'add', 'line' ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [], [ 'line' ] ) ] ), + $this->getMockDiff( [ $this->getMockDiffOp( 'copy', [], [ 'line' ] ) ] ), + ]; + + $otherTestCases = []; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1' ] ) ] ), + [ [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'add', [], [ 'a1', 'a2' ] ) ] ), + [ + [ 'action' => 'add', 'new' => 'a1', 'newline' => 1 ], + [ 'action' => 'add', 'new' => 'a2', 'newline' => 2 ], + ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1' ] ) ] ), + [ [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'delete', [ 'd1', 'd2' ] ) ] ), + [ + [ 'action' => 'delete', 'old' => 'd1', 'oldline' => 1 ], + [ 'action' => 'delete', 'old' => 'd2', 'oldline' => 2 ], + ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( 'change', [ 'd1' ], [ 'a1' ] ) ] ), + [ [ + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ] ], + ]; + $otherTestCases[] = [ + $this->getMockDiff( [ $this->getMockDiffOp( + 'change', + [ 'd1', 'd2' ], + [ 'a1', 'a2' ] + ) ] ), + [ + [ + 'action' => 'change', + 'old' => 'd1', + 'new' => 'mockLine', + 'newline' => 1, 'oldline' => 1 + ], + [ + 'action' => 'change', + 'old' => 'd2', + 'new' => 'mockLine', + 'newline' => 2, 'oldline' => 2 + ], + ], + ]; + + $testCases = []; + foreach ( $emptyArrayTestCases as $testCase ) { + $testCases[] = [ $testCase, [] ]; + } + foreach ( $otherTestCases as $testCase ) { + $testCases[] = [ $testCase[0], $testCase[1] ]; + } + return $testCases; + } + +} diff --git a/tests/phpunit/includes/diff/DiffOpTest.php b/tests/phpunit/includes/diff/DiffOpTest.php new file mode 100644 index 0000000000..3026fad6bd --- /dev/null +++ b/tests/phpunit/includes/diff/DiffOpTest.php @@ -0,0 +1,68 @@ +type = 'foo'; + $this->assertEquals( 'foo', $obj->getType() ); + } + + /** + * @covers DiffOp::getOrig + */ + public function testGetOrig() { + $obj = new FakeDiffOp(); + $obj->orig = [ 'foo' ]; + $this->assertEquals( [ 'foo' ], $obj->getOrig() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosing() { + $obj = new FakeDiffOp(); + $obj->closing = [ 'foo' ]; + $this->assertEquals( [ 'foo' ], $obj->getClosing() ); + } + + /** + * @covers DiffOp::getClosing + */ + public function testGetClosingWithParameter() { + $obj = new FakeDiffOp(); + $obj->closing = [ 'foo', 'bar', 'baz' ]; + $this->assertEquals( 'foo', $obj->getClosing( 0 ) ); + $this->assertEquals( 'bar', $obj->getClosing( 1 ) ); + $this->assertEquals( 'baz', $obj->getClosing( 2 ) ); + $this->assertEquals( null, $obj->getClosing( 3 ) ); + } + + /** + * @covers DiffOp::norig + */ + public function testNorig() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->norig() ); + $obj->orig = [ 'foo' ]; + $this->assertEquals( 1, $obj->norig() ); + } + + /** + * @covers DiffOp::nclosing + */ + public function testNclosing() { + $obj = new FakeDiffOp(); + $this->assertEquals( 0, $obj->nclosing() ); + $obj->closing = [ 'foo' ]; + $this->assertEquals( 1, $obj->nclosing() ); + } + +} diff --git a/tests/phpunit/includes/diff/DiffTest.php b/tests/phpunit/includes/diff/DiffTest.php new file mode 100644 index 0000000000..da6d7d9544 --- /dev/null +++ b/tests/phpunit/includes/diff/DiffTest.php @@ -0,0 +1,19 @@ +edits = 'FooBarBaz'; + $this->assertEquals( 'FooBarBaz', $obj->getEdits() ); + } + +} diff --git a/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php b/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php new file mode 100644 index 0000000000..fe129b751a --- /dev/null +++ b/tests/phpunit/includes/diff/DifferenceEngineSlotDiffRendererTest.php @@ -0,0 +1,44 @@ +getDiff( $oldContent, $newContent ); + $this->assertEquals( 'xxx|yyy', $diff ); + + $diff = $slotDiffRenderer->getDiff( null, $newContent ); + $this->assertEquals( '|yyy', $diff ); + + $diff = $slotDiffRenderer->getDiff( $oldContent, null ); + $this->assertEquals( 'xxx|', $diff ); + } + + public function testAddModules() { + $output = $this->getMockBuilder( OutputPage::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'addModules' ] ) + ->getMock(); + $output->expects( $this->once() ) + ->method( 'addModules' ) + ->with( 'foo' ); + $differenceEngine = new CustomDifferenceEngine(); + $slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine ); + $slotDiffRenderer->addModules( $output ); + } + + public function testGetExtraCacheKeys() { + $differenceEngine = new CustomDifferenceEngine(); + $slotDiffRenderer = new DifferenceEngineSlotDiffRenderer( $differenceEngine ); + $extraCacheKeys = $slotDiffRenderer->getExtraCacheKeys(); + $this->assertSame( [ 'foo' ], $extraCacheKeys ); + } + +} diff --git a/tests/phpunit/includes/diff/SlotDiffRendererTest.php b/tests/phpunit/includes/diff/SlotDiffRendererTest.php new file mode 100644 index 0000000000..a03280ddb2 --- /dev/null +++ b/tests/phpunit/includes/diff/SlotDiffRendererTest.php @@ -0,0 +1,78 @@ +getMockBuilder( SlotDiffRenderer::class ) + ->getMock(); + try { + // __call needs help deciding which parameter to take by reference + call_user_func_array( [ TestingAccessWrapper::newFromObject( $slotDiffRenderer ), + 'normalizeContents' ], [ &$oldContent, &$newContent, $allowedClasses ] ); + $this->assertEquals( $expectedOldContent, $oldContent ); + $this->assertEquals( $expectedNewContent, $newContent ); + } catch ( Exception $e ) { + if ( !$expectedExceptionClass ) { + throw $e; + } + $this->assertInstanceOf( $expectedExceptionClass, $e ); + } + } + + public function provideNormalizeContents() { + return [ + 'both null' => [ null, null, null, null, null, InvalidArgumentException::class ], + 'left null' => [ + null, new WikitextContent( 'abc' ), null, + new WikitextContent( '' ), new WikitextContent( 'abc' ), null, + ], + 'right null' => [ + new WikitextContent( 'def' ), null, null, + new WikitextContent( 'def' ), new WikitextContent( '' ), null, + ], + 'type filter' => [ + new WikitextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class, + new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null, + ], + 'type filter (subclass)' => [ + new WikitextContent( 'abc' ), new WikitextContent( 'def' ), TextContent::class, + new WikitextContent( 'abc' ), new WikitextContent( 'def' ), null, + ], + 'type filter (null)' => [ + new WikitextContent( 'abc' ), null, TextContent::class, + new WikitextContent( 'abc' ), new WikitextContent( '' ), null, + ], + 'type filter failure (left)' => [ + new TextContent( 'abc' ), new WikitextContent( 'def' ), WikitextContent::class, + null, null, ParameterTypeException::class, + ], + 'type filter failure (right)' => [ + new WikitextContent( 'abc' ), new TextContent( 'def' ), WikitextContent::class, + null, null, ParameterTypeException::class, + ], + 'type filter (array syntax)' => [ + new WikitextContent( 'abc' ), new JsonContent( 'def' ), + [ JsonContent::class, WikitextContent::class ], + new WikitextContent( 'abc' ), new JsonContent( 'def' ), null, + ], + 'type filter failure (array syntax)' => [ + new WikitextContent( 'abc' ), new CssContent( 'def' ), + [ JsonContent::class, WikitextContent::class ], + null, null, ParameterTypeException::class, + ], + ]; + } + +} diff --git a/tests/phpunit/includes/exception/HttpErrorTest.php b/tests/phpunit/includes/exception/HttpErrorTest.php new file mode 100644 index 0000000000..90ccd1e551 --- /dev/null +++ b/tests/phpunit/includes/exception/HttpErrorTest.php @@ -0,0 +1,63 @@ +assertFalse( $httpError->isLoggable(), 'http error is not loggable' ); + } + + public function testGetStatusCode() { + $httpError = new HttpError( 500, 'server error!' ); + $this->assertEquals( 500, $httpError->getStatusCode() ); + } + + /** + * @dataProvider getHtmlProvider + */ + public function testGetHtml( array $expected, $content, $header ) { + $httpError = new HttpError( 500, $content, $header ); + $errorHtml = $httpError->getHTML(); + + foreach ( $expected as $key => $html ) { + $this->assertContains( $html, $errorHtml, $key ); + } + } + + public function getHtmlProvider() { + return [ + [ + [ + 'head html' => 'Server Error 123', + 'body html' => '

Server Error 123

' + . '

a server error!

' + ], + 'a server error!', + 'Server Error 123' + ], + [ + [ + 'head html' => 'loginerror', + 'body html' => '

loginerror

' + . '

suspicious-userlogout

' + ], + new RawMessage( 'suspicious-userlogout' ), + new RawMessage( 'loginerror' ) + ], + [ + [ + 'head html' => 'Internal Server Error', + 'body html' => '

Internal Server Error

' + . '

a server error!

' + ], + 'a server error!', + null + ] + ]; + } +} diff --git a/tests/phpunit/includes/exception/MWExceptionHandlerTest.php b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php new file mode 100644 index 0000000000..6606065660 --- /dev/null +++ b/tests/phpunit/includes/exception/MWExceptionHandlerTest.php @@ -0,0 +1,74 @@ +getTrace(); + $hasObject = false; + $hasArray = false; + foreach ( $trace as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $hasObject = $hasObject || is_object( $arg ); + $hasArray = $hasArray || is_array( $arg ); + } + + if ( $hasObject && $hasArray ) { + break; + } + } + $this->assertTrue( $hasObject, + "The stacktrace must have a function having an object has parameter" ); + $this->assertTrue( $hasArray, + "The stacktrace must have a function having an array has parameter" ); + + # Now we redact the trace.. and make sure no function arguments are + # arrays or objects. + $redacted = MWExceptionHandler::getRedactedTrace( $e ); + + foreach ( $redacted as $frame ) { + if ( !isset( $frame['args'] ) ) { + continue; + } + foreach ( $frame['args'] as $arg ) { + $this->assertNotInternalType( 'array', $arg ); + $this->assertNotInternalType( 'object', $arg ); + } + } + + $this->assertEquals( 'value', $refvar, 'Ensuring reference variable wasn\'t changed' ); + } + + /** + * Helper function for testExpandArgumentsInCall + * + * Pass it an object and an array, and something by reference :-) + * + * @throws Exception + */ + protected static function helperThrowAnException( $a, $b, &$c ) { + throw new Exception(); + } +} diff --git a/tests/phpunit/includes/exception/ReadOnlyErrorTest.php b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php new file mode 100644 index 0000000000..ee5becffc6 --- /dev/null +++ b/tests/phpunit/includes/exception/ReadOnlyErrorTest.php @@ -0,0 +1,16 @@ +assertEquals( 'readonly', $e->title ); + $this->assertEquals( 'readonlytext', $e->msg ); + $this->assertEquals( wfReadOnlyReason() ?: [], $e->params ); + } + +} diff --git a/tests/phpunit/includes/exception/UserNotLoggedInTest.php b/tests/phpunit/includes/exception/UserNotLoggedInTest.php new file mode 100644 index 0000000000..55ec45a020 --- /dev/null +++ b/tests/phpunit/includes/exception/UserNotLoggedInTest.php @@ -0,0 +1,16 @@ +assertEquals( 'exception-nologin', $e->title ); + $this->assertEquals( 'exception-nologin-text', $e->msg ); + $this->assertEquals( [], $e->params ); + } + +} diff --git a/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php new file mode 100644 index 0000000000..f762693864 --- /dev/null +++ b/tests/phpunit/includes/externalstore/ExternalStoreFactoryTest.php @@ -0,0 +1,41 @@ +assertFalse( $factory->getStoreObject( 'ForTesting' ) ); + $this->assertFalse( $factory->getStoreObject( 'foo' ) ); + } + + public function provideStoreNames() { + yield 'Same case as construction' => [ 'ForTesting' ]; + yield 'All lower case' => [ 'fortesting' ]; + yield 'All upper case' => [ 'FORTESTING' ]; + yield 'Mix of cases' => [ 'FOrTEsTInG' ]; + } + + /** + * @dataProvider provideStoreNames + */ + public function testExternalStoreFactory_someStore_protoMatch( $proto ) { + $factory = new ExternalStoreFactory( [ 'ForTesting' ] ); + $store = $factory->getStoreObject( $proto ); + $this->assertInstanceOf( ExternalStoreForTesting::class, $store ); + } + + /** + * @dataProvider provideStoreNames + */ + public function testExternalStoreFactory_someStore_noProtoMatch( $proto ) { + $factory = new ExternalStoreFactory( [ 'SomeOtherClassName' ] ); + $store = $factory->getStoreObject( $proto ); + $this->assertFalse( $store ); + } + +} diff --git a/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php b/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php new file mode 100644 index 0000000000..35eca28f7e --- /dev/null +++ b/tests/phpunit/includes/filebackend/SwiftFileBackendTest.php @@ -0,0 +1,216 @@ +backend = TestingAccessWrapper::newFromObject( + new SwiftFileBackend( [ + 'name' => 'local-swift-testing', + 'class' => SwiftFileBackend::class, + 'wikiId' => 'unit-testing', + 'lockManager' => LockManagerGroup::singleton()->get( 'fsLockManager' ), + 'swiftAuthUrl' => 'http://127.0.0.1:8080/auth', // unused + 'swiftUser' => 'test:tester', + 'swiftKey' => 'testing', + 'swiftTempUrlKey' => 'b3968d0207b54ece87cccc06515a89d4' // unused + ] ) + ); + } + + /** + * @dataProvider provider_testSanitizeHdrsStrict + */ + public function testSanitizeHdrsStrict( $raw, $sanitized ) { + $hdrs = $this->backend->sanitizeHdrsStrict( [ 'headers' => $raw ] ); + + $this->assertEquals( $hdrs, $sanitized, 'sanitizeHdrsStrict() has expected result' ); + } + + public static function provider_testSanitizeHdrsStrict() { + return [ + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'inline', + 'content-duration' => 35.6363, + 'content-Custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-disposition' => 'inline', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ], + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-Disposition' => 'inline; filename=xxx; ' . str_repeat( 'o', 1024 ), + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-disposition' => 'inline;filename=xxx', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ], + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'filename=' . str_repeat( 'o', 1024 ) . ';inline', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-disposition' => '', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ] + ]; + } + + /** + * @dataProvider provider_testSanitizeHdrs + */ + public function testSanitizeHdrs( $raw, $sanitized ) { + $hdrs = $this->backend->sanitizeHdrs( [ 'headers' => $raw ] ); + + $this->assertEquals( $hdrs, $sanitized, 'sanitizeHdrs() has expected result' ); + } + + public static function provider_testSanitizeHdrs() { + return [ + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'inline', + 'content-duration' => 35.6363, + 'content-Custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'inline', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ], + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-Disposition' => 'inline; filename=xxx; ' . str_repeat( 'o', 1024 ), + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'inline;filename=xxx', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ], + [ + [ + 'content-length' => 345, + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => 'filename=' . str_repeat( 'o', 1024 ) . ';inline', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ], + [ + 'content-type' => 'image+bitmap/jpeg', + 'content-disposition' => '', + 'content-duration' => 35.6363, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello' + ] + ] + ]; + } + + /** + * @dataProvider provider_testGetMetadataHeaders + */ + public function testGetMetadataHeaders( $raw, $sanitized ) { + $hdrs = $this->backend->getMetadataHeaders( $raw ); + + $this->assertEquals( $hdrs, $sanitized, 'getMetadataHeaders() has expected result' ); + } + + public static function provider_testGetMetadataHeaders() { + return [ + [ + [ + 'content-length' => 345, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello', + 'x-object-meta-custom' => 5, + 'x-object-meta-sha1Base36' => 'a3deadfg...', + ], + [ + 'x-object-meta-custom' => 5, + 'x-object-meta-sha1base36' => 'a3deadfg...', + ] + ] + ]; + } + + /** + * @dataProvider provider_testGetMetadata + */ + public function testGetMetadata( $raw, $sanitized ) { + $hdrs = $this->backend->getMetadata( $raw ); + + $this->assertEquals( $hdrs, $sanitized, 'getMetadata() has expected result' ); + } + + public static function provider_testGetMetadata() { + return [ + [ + [ + 'content-length' => 345, + 'content-custom' => 'hello', + 'x-content-custom' => 'hello', + 'x-object-meta-custom' => 5, + 'x-object-meta-sha1Base36' => 'a3deadfg...', + ], + [ + 'custom' => 5, + 'sha1base36' => 'a3deadfg...', + ] + ] + ]; + } +} diff --git a/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php b/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php new file mode 100644 index 0000000000..346be7afa3 --- /dev/null +++ b/tests/phpunit/includes/filerepo/FileBackendDBRepoWrapperTest.php @@ -0,0 +1,140 @@ +expects( $dbReadsExpected ) + ->method( 'selectField' ) + ->will( $this->returnValue( $dbReturnValue ) ); + + $newPaths = $wrapperMock->getBackendPaths( [ $originalPath ], $latest ); + + $this->assertEquals( + $expectedBackendPath, + $newPaths[0], + $message ); + } + + public function getBackendPathsProvider() { + $prefix = 'mwstore://' . $this->backendName . '/' . $this->repoName; + $mocksForCaching = $this->getMocks(); + + return [ + [ + $mocksForCaching, + false, + $this->once(), + '96246614d75ba1703bdfd5d7660bb57407aaf5d9', + $prefix . '-public/f/o/foobar.jpg', + $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', + 'Public path translated correctly', + ], + [ + $mocksForCaching, + false, + $this->never(), + '96246614d75ba1703bdfd5d7660bb57407aaf5d9', + $prefix . '-public/f/o/foobar.jpg', + $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', + 'LRU cache leveraged', + ], + [ + $this->getMocks(), + true, + $this->once(), + '96246614d75ba1703bdfd5d7660bb57407aaf5d9', + $prefix . '-public/f/o/foobar.jpg', + $prefix . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9', + 'Latest obtained', + ], + [ + $this->getMocks(), + true, + $this->never(), + '96246614d75ba1703bdfd5d7660bb57407aaf5d9', + $prefix . '-deleted/f/o/foobar.jpg', + $prefix . '-original/f/o/o/foobar', + 'Deleted path translated correctly', + ], + [ + $this->getMocks(), + true, + $this->once(), + null, + $prefix . '-public/b/a/baz.jpg', + $prefix . '-public/b/a/baz.jpg', + 'Path left untouched if no sha1 can be found', + ], + ]; + } + + /** + * @covers FileBackendDBRepoWrapper::getFileContentsMulti + */ + public function testGetFileContentsMulti() { + list( $dbMock, $backendMock, $wrapperMock ) = $this->getMocks(); + + $sha1Path = 'mwstore://' . $this->backendName . '/' . $this->repoName + . '-original/9/6/2/96246614d75ba1703bdfd5d7660bb57407aaf5d9'; + $filenamePath = 'mwstore://' . $this->backendName . '/' . $this->repoName + . '-public/f/o/foobar.jpg'; + + $dbMock->expects( $this->once() ) + ->method( 'selectField' ) + ->will( $this->returnValue( '96246614d75ba1703bdfd5d7660bb57407aaf5d9' ) ); + + $backendMock->expects( $this->once() ) + ->method( 'getFileContentsMulti' ) + ->will( $this->returnValue( [ $sha1Path => 'foo' ] ) ); + + $result = $wrapperMock->getFileContentsMulti( [ 'srcs' => [ $filenamePath ] ] ); + + $this->assertEquals( + [ $filenamePath => 'foo' ], + $result, + 'File contents paths translated properly' + ); + } + + protected function getMocks() { + $dbMock = $this->getMockBuilder( Wikimedia\Rdbms\IDatabase::class ) + ->disableOriginalClone() + ->disableOriginalConstructor() + ->getMock(); + + $backendMock = $this->getMockBuilder( FSFileBackend::class ) + ->setConstructorArgs( [ [ + 'name' => $this->backendName, + 'wikiId' => wfWikiID() + ] ] ) + ->getMock(); + + $wrapperMock = $this->getMockBuilder( FileBackendDBRepoWrapper::class ) + ->setMethods( [ 'getDB' ] ) + ->setConstructorArgs( [ [ + 'backend' => $backendMock, + 'repoName' => $this->repoName, + 'dbHandleFactory' => null + ] ] ) + ->getMock(); + + $wrapperMock->expects( $this->any() )->method( 'getDB' )->will( $this->returnValue( $dbMock ) ); + + return [ $dbMock, $backendMock, $wrapperMock ]; + } +} diff --git a/tests/phpunit/includes/filerepo/FileRepoTest.php b/tests/phpunit/includes/filerepo/FileRepoTest.php new file mode 100644 index 0000000000..0d3e679aa8 --- /dev/null +++ b/tests/phpunit/includes/filerepo/FileRepoTest.php @@ -0,0 +1,55 @@ + 'foobar' + ] ); + } + + /** + * @expectedException MWException + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionOptionNeedBackendKey() { + new FileRepo( [ + 'name' => 'foobar' + ] ); + } + + /** + * @covers FileRepo::__construct + */ + public function testFileRepoConstructionWithRequiredOptions() { + $f = new FileRepo( [ + 'name' => 'FileRepoTestRepository', + 'backend' => new FSFileBackend( [ + 'name' => 'local-testing', + 'wikiId' => 'test_wiki', + 'containerPaths' => [] + ] ) + ] ); + $this->assertInstanceOf( FileRepo::class, $f ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php new file mode 100644 index 0000000000..eaba22d7fb --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLAutoCompleteSelectFieldTest.php @@ -0,0 +1,66 @@ + 'BGR', + 'Burkina Faso' => 'BFA', + 'Burundi' => 'BDI', + ]; + + /** + * Verify that attempting to instantiate an HTMLAutoCompleteSelectField + * without providing any autocomplete options causes an exception to be + * thrown. + * + * @expectedException MWException + * @expectedExceptionMessage called without any autocompletions + */ + function testMissingAutocompletions() { + new HTMLAutoCompleteSelectField( [ 'fieldname' => 'Test' ] ); + } + + /** + * Verify that the autocomplete options are correctly encoded as + * the 'data-autocomplete' attribute of the field. + * + * @covers HTMLAutoCompleteSelectField::getAttributes + */ + function testGetAttributes() { + $field = new HTMLAutoCompleteSelectField( [ + 'fieldname' => 'Test', + 'autocomplete' => $this->options, + ] ); + + $attributes = $field->getAttributes( [] ); + $this->assertEquals( array_keys( $this->options ), + FormatJson::decode( $attributes['data-autocomplete'] ), + "The 'data-autocomplete' attribute encodes autocomplete option keys as a JSON array." + ); + } + + /** + * Test that the optional select dropdown is included or excluded based on + * the presence or absence of the 'options' parameter. + */ + function testOptionalSelectElement() { + $params = [ + 'fieldname' => 'Test', + 'autocomplete-data' => $this->options, + 'options' => $this->options, + ]; + + $field = new HTMLAutoCompleteSelectField( $params ); + $html = $field->getInputHTML( false ); + $this->assertRegExp( '/select/', $html, + "When the 'options' parameter is set, the HTML includes a " ); + } +} diff --git a/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php new file mode 100644 index 0000000000..05c567df75 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLCheckMatrixTest.php @@ -0,0 +1,104 @@ + [ 'r1', 'r2' ], + 'columns' => [ 'c1', 'c2' ], + 'fieldname' => 'test', + ]; + + public function testPlainInstantiation() { + try { + new HTMLCheckMatrix( [] ); + } catch ( MWException $e ) { + $this->assertInstanceOf( HTMLFormFieldRequiredOptionsException::class, $e ); + return; + } + + $this->fail( 'Expected MWException indicating missing parameters but none was thrown.' ); + } + + public function testInstantiationWithMinimumRequiredParameters() { + new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( true ); // form instantiation must throw exception on failure + } + + public function testValidateCallsUserDefinedValidationCallback() { + $called = false; + $field = new HTMLCheckMatrix( self::$defaultOptions + [ + 'validation-callback' => function () use ( &$called ) { + $called = true; + + return false; + }, + ] ); + $this->assertEquals( false, $this->validate( $field, [] ) ); + $this->assertTrue( $called ); + } + + public function testValidateRequiresArrayInput() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertEquals( false, $this->validate( $field, null ) ); + $this->assertEquals( false, $this->validate( $field, true ) ); + $this->assertEquals( false, $this->validate( $field, 'abc' ) ); + $this->assertEquals( false, $this->validate( $field, new stdClass ) ); + $this->assertEquals( true, $this->validate( $field, [] ) ); + } + + public function testValidateAllowsOnlyKnownTags() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertInstanceOf( Message::class, $this->validate( $field, [ 'foo' ] ) ); + } + + public function testValidateAcceptsPartialTagList() { + $field = new HTMLCheckMatrix( self::$defaultOptions ); + $this->assertTrue( $this->validate( $field, [] ) ); + $this->assertTrue( $this->validate( $field, [ 'c1-r1' ] ) ); + $this->assertTrue( $this->validate( $field, [ 'c1-r1', 'c1-r2', 'c2-r1', 'c2-r2' ] ) ); + } + + /** + * This form object actually has no visibility into what happens later on, but essentially + * if the data submitted by the user passes validate the following is run: + * foreach ( $field->filterDataForSubmit( $data ) as $k => $v ) { + * $user->setOption( $k, $v ); + * } + */ + public function testValuesForcedOnRemainOn() { + $field = new HTMLCheckMatrix( self::$defaultOptions + [ + 'force-options-on' => [ 'c2-r1' ], + ] ); + $expected = [ + 'c1-r1' => false, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ]; + $this->assertEquals( $expected, $field->filterDataForSubmit( [] ) ); + } + + public function testValuesForcedOffRemainOff() { + $field = new HTMLCheckMatrix( self::$defaultOptions + [ + 'force-options-off' => [ 'c1-r2', 'c2-r2' ], + ] ); + $expected = [ + 'c1-r1' => true, + 'c1-r2' => false, + 'c2-r1' => true, + 'c2-r2' => false, + ]; + // array_keys on the result simulates submitting all fields checked + $this->assertEquals( $expected, $field->filterDataForSubmit( array_keys( $expected ) ) ); + } + + protected function validate( HTMLFormField $field, $submitted ) { + return $field->validate( + $submitted, + [ self::$defaultOptions['fieldname'] => $submitted ] + ); + } + +} diff --git a/tests/phpunit/includes/htmlform/HTMLFormTest.php b/tests/phpunit/includes/htmlform/HTMLFormTest.php new file mode 100644 index 0000000000..d7dc4112b7 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLFormTest.php @@ -0,0 +1,63 @@ +setTitle( Title::newFromText( 'Foo' ) ); + return $form; + } + + public function testGetHTML_empty() { + $form = $this->newInstance(); + $form->prepareForm(); + $html = $form->getHTML( false ); + $this->assertStringStartsWith( '
newInstance(); + $form->getHTML( false ); + } + + public function testAutocompleteDefaultsToNull() { + $form = $this->newInstance(); + $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) ); + } + + public function testAutocompleteWhenSetToNull() { + $form = $this->newInstance(); + $form->setAutocomplete( null ); + $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) ); + } + + public function testAutocompleteWhenSetToFalse() { + $form = $this->newInstance(); + // Previously false was used instead of null to indicate the attribute should not be set + $form->setAutocomplete( false ); + $this->assertNotContains( 'autocomplete', $form->wrapForm( '' ) ); + } + + public function testAutocompleteWhenSetToOff() { + $form = $this->newInstance(); + $form->setAutocomplete( 'off' ); + $this->assertContains( ' autocomplete="off"', $form->wrapForm( '' ) ); + } + + public function testGetPreText() { + $preText = 'TEST'; + $form = $this->newInstance(); + $form->setPreText( $preText ); + $this->assertSame( $preText, $form->getPreText() ); + } + +} diff --git a/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php b/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php new file mode 100644 index 0000000000..c4290e1e64 --- /dev/null +++ b/tests/phpunit/includes/htmlform/HTMLRestrictionsFieldTest.php @@ -0,0 +1,71 @@ + 'restrictions' ] ); + $this->assertNotEmpty( $field->getLabel(), 'has a default label' ); + $this->assertNotEmpty( $field->getHelpText(), 'has a default help text' ); + $this->assertEquals( MWRestrictions::newDefault(), $field->getDefault(), + 'defaults to the default MWRestrictions object' ); + + $field = new HTMLRestrictionsField( [ + 'fieldname' => 'restrictions', + 'label' => 'foo', + 'help' => 'bar', + 'default' => 'baz', + ] ); + $this->assertEquals( 'foo', $field->getLabel(), 'label can be customized' ); + $this->assertEquals( 'bar', $field->getHelpText(), 'help text can be customized' ); + $this->assertEquals( 'baz', $field->getDefault(), 'default can be customized' ); + } + + /** + * @dataProvider provideValidate + */ + public function testForm( $text, $value ) { + $form = HTMLForm::factory( 'ooui', [ + 'restrictions' => [ 'class' => HTMLRestrictionsField::class ], + ] ); + $request = new FauxRequest( [ 'wprestrictions' => $text ], true ); + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( $request ); + $form->setContext( $context ); + $form->setTitle( Title::newFromText( 'Main Page' ) )->setSubmitCallback( function () { + return true; + } )->prepareForm(); + $status = $form->trySubmit(); + + if ( $status instanceof StatusValue ) { + $this->assertEquals( $value !== false, $status->isGood() ); + } elseif ( $value === false ) { + $this->assertNotSame( true, $status ); + } else { + $this->assertSame( true, $status ); + } + + if ( $value !== false ) { + $restrictions = $form->mFieldData['restrictions']; + $this->assertInstanceOf( MWRestrictions::class, $restrictions ); + $this->assertEquals( $value, $restrictions->toArray()['IPAddresses'] ); + } + + // sanity + $form->getHTML( $status ); + } + + public function provideValidate() { + return [ + // submitted text, value of 'IPAddresses' key or false for validation error + [ null, [ '0.0.0.0/0', '::/0' ] ], + [ '', [] ], + [ "1.2.3.4\n::/0", [ '1.2.3.4', '::/0' ] ], + [ "1.2.3.4\n::/x", false ], + ]; + } +} diff --git a/tests/phpunit/includes/http/GuzzleHttpRequestTest.php b/tests/phpunit/includes/http/GuzzleHttpRequestTest.php new file mode 100644 index 0000000000..c9356b6b10 --- /dev/null +++ b/tests/phpunit/includes/http/GuzzleHttpRequestTest.php @@ -0,0 +1,151 @@ +bodyTextReceived .= $buffer; + return strlen( $buffer ); + } + + public function testSuccess() { + $handler = HandlerStack::create( new MockHandler( [ new Response( 200, [ + 'status' => 200, + ], $this->exampleBodyText ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ 'handler' => $handler ] ); + $r->execute(); + + $this->assertEquals( 200, $r->getStatus() ); + $this->assertEquals( $this->exampleBodyText, $r->getContent() ); + } + + public function testSuccessConstructorCallback() { + $this->bodyTextReceived = ''; + $handler = HandlerStack::create( new MockHandler( [ new Response( 200, [ + 'status' => 200, + ], $this->exampleBodyText ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ + 'callback' => [ $this, 'processHttpDataChunk' ], + 'handler' => $handler, + ] ); + $r->execute(); + + $this->assertEquals( 200, $r->getStatus() ); + $this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived ); + } + + public function testSuccessSetCallback() { + $this->bodyTextReceived = ''; + $handler = HandlerStack::create( new MockHandler( [ new Response( 200, [ + 'status' => 200, + ], $this->exampleBodyText ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ + 'handler' => $handler, + ] ); + $r->setCallback( [ $this, 'processHttpDataChunk' ] ); + $r->execute(); + + $this->assertEquals( 200, $r->getStatus() ); + $this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived ); + } + + /** + * use a callback stream to pipe the mocked response data to our callback function + */ + public function testSuccessSink() { + $this->bodyTextReceived = ''; + $handler = HandlerStack::create( new MockHandler( [ new Response( 200, [ + 'status' => 200, + ], $this->exampleBodyText ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ + 'handler' => $handler, + 'sink' => new MWCallbackStream( [ $this, 'processHttpDataChunk' ] ), + ] ); + $r->execute(); + + $this->assertEquals( 200, $r->getStatus() ); + $this->assertEquals( $this->exampleBodyText, $this->bodyTextReceived ); + } + + public function testBadUrl() { + $r = new GuzzleHttpRequest( '' ); + $s = $r->execute(); + $errorMsg = $s->getErrorsByType( 'error' )[0]['message']; + + $this->assertEquals( 0, $r->getStatus() ); + $this->assertEquals( 'http-invalid-url', $errorMsg ); + } + + public function testConnectException() { + $handler = HandlerStack::create( new MockHandler( [ new GuzzleHttp\Exception\ConnectException( + 'Mock Connection Exception', new Request( 'GET', $this->exampleUrl ) + ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ 'handler' => $handler ] ); + $s = $r->execute(); + $errorMsg = $s->getErrorsByType( 'error' )[0]['message']; + + $this->assertEquals( 0, $r->getStatus() ); + $this->assertEquals( 'http-request-error', $errorMsg ); + } + + public function testTimeout() { + $handler = HandlerStack::create( new MockHandler( [ new GuzzleHttp\Exception\RequestException( + 'Connection timed out', new Request( 'GET', $this->exampleUrl ) + ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ 'handler' => $handler ] ); + $s = $r->execute(); + $errorMsg = $s->getErrorsByType( 'error' )[0]['message']; + + $this->assertEquals( 0, $r->getStatus() ); + $this->assertEquals( 'http-timed-out', $errorMsg ); + } + + public function testNotFound() { + $handler = HandlerStack::create( new MockHandler( [ new Response( 404, [ + 'status' => '404', + ] ) ] ) ); + $r = new GuzzleHttpRequest( $this->exampleUrl, [ 'handler' => $handler ] ); + $s = $r->execute(); + $errorMsg = $s->getErrorsByType( 'error' )[0]['message']; + + $this->assertEquals( 404, $r->getStatus() ); + $this->assertEquals( 'http-bad-status', $errorMsg ); + } +} diff --git a/tests/phpunit/includes/http/HttpRequestFactoryTest.php b/tests/phpunit/includes/http/HttpRequestFactoryTest.php new file mode 100644 index 0000000000..7429dcc9dd --- /dev/null +++ b/tests/phpunit/includes/http/HttpRequestFactoryTest.php @@ -0,0 +1,119 @@ +getMockBuilder( HttpRequestFactory::class ) + ->setMethods( [ 'create' ] ) + ->getMock(); + + $factory->method( 'create' ) + ->willReturnCallback( + function ( $url, array $options = [], $caller = __METHOD__ ) + use ( $req, $expectedUrl, $expectedOptions ) + { + $this->assertSame( $url, $expectedUrl ); + + foreach ( $expectedOptions as $opt => $exp ) { + $this->assertArrayHasKey( $opt, $options ); + $this->assertSame( $exp, $options[$opt] ); + } + + return $req; + } + ); + + return $factory; + } + + /** + * @return MWHttpRequest + */ + private function newFakeRequest( $result ) { + $req = $this->getMockBuilder( MWHttpRequest::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getContent', 'execute' ] ) + ->getMock(); + + if ( $result instanceof Status ) { + $req->method( 'getContent' ) + ->willReturn( $result->getValue() ); + $req->method( 'execute' ) + ->willReturn( $result ); + } else { + $req->method( 'getContent' ) + ->willReturn( $result ); + $req->method( 'execute' ) + ->willReturn( Status::newGood( $result ) ); + } + + return $req; + } + + public function testCreate() { + $factory = $this->newFactory(); + $this->assertInstanceOf( 'MWHttpRequest', $factory->create( 'http://example.test' ) ); + } + + public function testGetUserAgent() { + $factory = $this->newFactory(); + $this->assertStringStartsWith( 'MediaWiki/', $factory->getUserAgent() ); + } + + public function testGet() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'GET' ] + ); + + $this->assertSame( __METHOD__, $factory->get( 'https://example.test' ) ); + } + + public function testPost() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'POST' ] + ); + + $this->assertSame( __METHOD__, $factory->post( 'https://example.test' ) ); + } + + public function testRequest() { + $req = $this->newFakeRequest( __METHOD__ ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'GET' ] + ); + + $this->assertSame( __METHOD__, $factory->request( 'GET', 'https://example.test' ) ); + } + + public function testRequest_failed() { + $status = Status::newFatal( 'testing' ); + $req = $this->newFakeRequest( $status ); + $factory = $this->newFactoryWithFakeRequest( + $req, 'https://example.test', [ 'method' => 'POST' ] + ); + + $this->assertNull( $factory->request( 'POST', 'https://example.test' ) ); + } + +} diff --git a/tests/phpunit/includes/installer/InstallDocFormatterTest.php b/tests/phpunit/includes/installer/InstallDocFormatterTest.php new file mode 100644 index 0000000000..9584d4b8c4 --- /dev/null +++ b/tests/phpunit/includes/installer/InstallDocFormatterTest.php @@ -0,0 +1,83 @@ +assertEquals( + $expected, + InstallDocFormatter::format( $unformattedText ), + $message + ); + } + + /** + * Provider for testFormat() + */ + public static function provideDocFormattingTests() { + # Format: (expected string, unformattedText string, optional message) + return [ + # Escape some wikitext + [ 'Install <tag>', 'Install ', 'Escaping <' ], + [ 'Install {{template}}', 'Install {{template}}', 'Escaping [[' ], + [ 'Install [[page]]', 'Install [[page]]', 'Escaping {{' ], + [ 'Install __TOC__', 'Install __TOC__', 'Escaping __' ], + [ 'Install ', "Install \r", 'Removing \r' ], + + # Transform \t{1,2} into :{1,2} + [ ':One indentation', "\tOne indentation", 'Replacing a single \t' ], + [ '::Two indentations', "\t\tTwo indentations", 'Replacing 2 x \t' ], + + # Transform 'T123' links + [ + '[https://phabricator.wikimedia.org/T123 T123]', + 'T123', 'Testing T123 links' ], + [ + 'bug [https://phabricator.wikimedia.org/T123 T123]', + 'bug T123', 'Testing bug T123 links' ], + [ + '([https://phabricator.wikimedia.org/T987654 T987654])', + '(T987654)', 'Testing (T987654) links' ], + + # "Tabc" shouldn't work + [ 'Tfoobar', 'Tfoobar', "Don't match T followed by non-digits" ], + [ 'T!!fakefake!!', 'T!!fakefake!!', "Don't match T followed by non-digits" ], + + # Transform 'bug 123' links + [ + '[https://bugzilla.wikimedia.org/123 bug 123]', + 'bug 123', 'Testing bug 123 links' ], + [ + '([https://bugzilla.wikimedia.org/987654 bug 987654])', + '(bug 987654)', 'Testing (bug 987654) links' ], + + # "bug abc" shouldn't work + [ 'bug foobar', 'bug foobar', "Don't match bug followed by non-digits" ], + [ 'bug !!fakefake!!', 'bug !!fakefake!!', "Don't match bug followed by non-digits" ], + + # Transform '$wgFooBar' links + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar $wgFooBar]', + '$wgFooBar', 'Testing basic $wgFooBar' ], + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFooBar45 $wgFooBar45]', + '$wgFooBar45', 'Testing $wgFooBar45 (with numbers)' ], + [ + '' + . '[https://www.mediawiki.org/wiki/Manual:$wgFoo_Bar $wgFoo_Bar]', + '$wgFoo_Bar', 'Testing $wgFoo_Bar (with underscore)' ], + + # Icky variables that shouldn't link + [ + '$myAwesomeVariable', + '$myAwesomeVariable', + 'Testing $myAwesomeVariable (not starting with $wg)' + ], + [ '$()not!a&Var', '$()not!a&Var', 'Testing $()not!a&Var (obviously not a variable)' ], + ]; + } +} diff --git a/tests/phpunit/includes/installer/OracleInstallerTest.php b/tests/phpunit/includes/installer/OracleInstallerTest.php new file mode 100644 index 0000000000..e255089de4 --- /dev/null +++ b/tests/phpunit/includes/installer/OracleInstallerTest.php @@ -0,0 +1,49 @@ +assertEquals( $expected, + OracleInstaller::checkConnectStringFormat( $connectString ), + $msg + ); + } + + /** + * Provider to test OracleInstaller::checkConnectStringFormat() + */ + function provideOracleConnectStrings() { + // expected result, connectString[, message] + return [ + [ true, 'simple_01', 'Simple TNS name' ], + [ true, 'simple_01.world', 'TNS name with domain' ], + [ true, 'simple_01.domain.net', 'TNS name with domain' ], + [ true, 'host123', 'Host only' ], + [ true, 'host123.domain.net', 'FQDN only' ], + [ true, '//host123.domain.net', 'FQDN URL only' ], + [ true, '123.223.213.132', 'Host IP only' ], + [ true, 'host:1521', 'Host and port' ], + [ true, 'host:1521/service', 'Host, port and service' ], + [ true, 'host:1521/service:shared', 'Host, port, service and shared server type' ], + [ true, 'host:1521/service:dedicated', 'Host, port, service and dedicated server type' ], + [ true, 'host:1521/service:pooled', 'Host, port, service and pooled server type' ], + [ + true, + 'host:1521/service:shared/instance1', + 'Host, port, service, server type and instance' + ], + [ true, 'host:1521//instance1', 'Host, port and instance' ], + ]; + } + +} diff --git a/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php new file mode 100644 index 0000000000..0a13de1d96 --- /dev/null +++ b/tests/phpunit/includes/interwiki/InterwikiLookupAdapterTest.php @@ -0,0 +1,133 @@ +interwikiLookup = new InterwikiLookupAdapter( + $this->getSiteLookup( $this->getSites() ) + ); + } + + public function testIsValidInterwiki() { + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'enwt' ), + 'enwt known prefix is valid' + ); + $this->assertTrue( + $this->interwikiLookup->isValidInterwiki( 'foo' ), + 'foo site known prefix is valid' + ); + $this->assertFalse( + $this->interwikiLookup->isValidInterwiki( 'xyz' ), + 'unknown prefix is not valid' + ); + } + + public function testFetch() { + $interwiki = $this->interwikiLookup->fetch( '' ); + $this->assertNull( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'xyz' ); + $this->assertFalse( $interwiki ); + + $interwiki = $this->interwikiLookup->fetch( 'foo' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + $this->assertSame( 'foobar', $interwiki->getWikiID() ); + + $interwiki = $this->interwikiLookup->fetch( 'enwt' ); + $this->assertInstanceOf( Interwiki::class, $interwiki ); + + $this->assertSame( 'https://en.wiktionary.org/wiki/$1', $interwiki->getURL(), 'getURL' ); + $this->assertSame( 'https://en.wiktionary.org/w/api.php', $interwiki->getAPI(), 'getAPI' ); + $this->assertSame( 'enwiktionary', $interwiki->getWikiID(), 'getWikiID' ); + $this->assertTrue( $interwiki->isLocal(), 'isLocal' ); + } + + public function testGetAllPrefixes() { + $foo = [ + 'iw_prefix' => 'foo', + 'iw_url' => '', + 'iw_api' => '', + 'iw_wikiid' => 'foobar', + 'iw_local' => false, + 'iw_trans' => false, + ]; + $enwt = [ + 'iw_prefix' => 'enwt', + 'iw_url' => 'https://en.wiktionary.org/wiki/$1', + 'iw_api' => 'https://en.wiktionary.org/w/api.php', + 'iw_wikiid' => 'enwiktionary', + 'iw_local' => true, + 'iw_trans' => false, + ]; + + $this->assertEquals( + [ $foo, $enwt ], + $this->interwikiLookup->getAllPrefixes(), + 'getAllPrefixes()' + ); + + $this->assertEquals( + [ $foo ], + $this->interwikiLookup->getAllPrefixes( false ), + 'get external prefixes' + ); + + $this->assertEquals( + [ $enwt ], + $this->interwikiLookup->getAllPrefixes( true ), + 'get local prefixes' + ); + } + + private function getSiteLookup( SiteList $sites ) { + $siteLookup = $this->getMockBuilder( SiteLookup::class ) + ->disableOriginalConstructor() + ->getMock(); + + $siteLookup->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnValue( $sites ) ); + + return $siteLookup; + } + + private function getSites() { + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'foobar' ); + $site->addInterwikiId( 'foo' ); + $site->setSource( 'external' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'enwiktionary' ); + $site->setGroup( 'wiktionary' ); + $site->setLanguageCode( 'en' ); + $site->addNavigationId( 'enwiktionary' ); + $site->addInterwikiId( 'enwt' ); + $site->setSource( 'local' ); + $site->setPath( MediaWikiSite::PATH_PAGE, "https://en.wiktionary.org/wiki/$1" ); + $site->setPath( MediaWikiSite::PATH_FILE, "https://en.wiktionary.org/w/$1" ); + $sites[] = $site; + + return new SiteList( $sites ); + } + +} diff --git a/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php b/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php new file mode 100644 index 0000000000..232b46a31b --- /dev/null +++ b/tests/phpunit/includes/jobqueue/JobQueueMemoryTest.php @@ -0,0 +1,63 @@ + JobQueueMemory::class, + 'domain' => WikiMap::getCurrentWikiDbDomain()->getId(), + 'type' => 'null', + ] ); + } + + private function newJobSpecification() { + return new JobSpecification( + 'null', + [ 'customParameter' => null ], + [], + Title::newFromText( 'Custom title' ) + ); + } + + public function testGetAllQueuedJobs() { + $queue = $this->newJobQueue(); + $this->assertCount( 0, $queue->getAllQueuedJobs() ); + + $queue->push( $this->newJobSpecification() ); + $this->assertCount( 1, $queue->getAllQueuedJobs() ); + } + + public function testGetAllAcquiredJobs() { + $queue = $this->newJobQueue(); + $this->assertCount( 0, $queue->getAllAcquiredJobs() ); + + $queue->push( $this->newJobSpecification() ); + $this->assertCount( 0, $queue->getAllAcquiredJobs() ); + + $queue->pop(); + $this->assertCount( 1, $queue->getAllAcquiredJobs() ); + } + + public function testJobFromSpecInternal() { + $queue = $this->newJobQueue(); + $job = $queue->jobFromSpecInternal( $this->newJobSpecification() ); + $this->assertInstanceOf( Job::class, $job ); + $this->assertSame( 'null', $job->getType() ); + $this->assertArrayHasKey( 'customParameter', $job->getParams() ); + $this->assertSame( 'Custom title', $job->getTitle()->getText() ); + } + +} diff --git a/tests/phpunit/includes/json/FormatJsonTest.php b/tests/phpunit/includes/json/FormatJsonTest.php new file mode 100644 index 0000000000..a6adf343d5 --- /dev/null +++ b/tests/phpunit/includes/json/FormatJsonTest.php @@ -0,0 +1,436 @@ + new stdClass, + 'emptyArray' => [], + 'string' => 'foobar\\', + 'filledArray' => [ + [ + 123, + 456, + ], + // Nested json works without problems + '"7":["8",{"9":"10"}]', + // Whitespace clean up doesn't touch strings that look alike + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}", + ], + ]; + + // No trailing whitespace, no trailing linefeed + $json = '{ + "emptyObject": {}, + "emptyArray": [], + "string": "foobar\\\\", + "filledArray": [ + [ + 123, + 456 + ], + "\"7\":[\"8\",{\"9\":\"10\"}]", + "{\n\t\"emptyObject\": {\n\t},\n\t\"emptyArray\": [ ]\n}" + ] +}'; + + $json = str_replace( "\r", '', $json ); // Windows compat + $json = str_replace( "\t", $expectedIndent, $json ); + $this->assertSame( $json, FormatJson::encode( $obj, $pretty ) ); + } + + public static function provideEncodeDefault() { + return self::getEncodeTestCases( [] ); + } + + /** + * @dataProvider provideEncodeDefault + */ + public function testEncodeDefault( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from ) ); + } + + public static function provideEncodeUtf8() { + return self::getEncodeTestCases( [ 'unicode' ] ); + } + + /** + * @dataProvider provideEncodeUtf8 + */ + public function testEncodeUtf8( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::UTF8_OK ) ); + } + + public static function provideEncodeXmlMeta() { + return self::getEncodeTestCases( [ 'xmlmeta' ] ); + } + + /** + * @dataProvider provideEncodeXmlMeta + */ + public function testEncodeXmlMeta( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::XMLMETA_OK ) ); + } + + public static function provideEncodeAllOk() { + return self::getEncodeTestCases( [ 'unicode', 'xmlmeta' ] ); + } + + /** + * @dataProvider provideEncodeAllOk + */ + public function testEncodeAllOk( $from, $to ) { + $this->assertSame( $to, FormatJson::encode( $from, false, FormatJson::ALL_OK ) ); + } + + public function testEncodePhpBug46944() { + $this->assertNotEquals( + '\ud840\udc00', + strtolower( FormatJson::encode( "\xf0\xa0\x80\x80" ) ), + 'Test encoding an broken json_encode character (U+20000)' + ); + } + + public function testEncodeFail() { + // Set up a recursive object that can't be encoded. + $a = new stdClass; + $b = new stdClass; + $a->b = $b; + $b->a = $a; + $this->assertFalse( FormatJson::encode( $a ) ); + } + + public function testDecodeReturnType() { + $this->assertInternalType( + 'object', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}' ), + 'Default to object' + ); + + $this->assertInternalType( + 'array', + FormatJson::decode( '{"Name": "Cheeso", "Rank": 7}', true ), + 'Optional array' + ); + } + + public static function provideParse() { + return [ + [ null ], + [ true ], + [ false ], + [ 0 ], + [ 1 ], + [ 1.2 ], + [ '' ], + [ 'str' ], + [ [ 0, 1, 2 ] ], + [ [ 'a' => 'b' ] ], + [ [ 'a' => 'b' ] ], + [ [ 'a' => 'b', 'x' => [ 'c' => 'd' ] ] ], + ]; + } + + /** + * Recursively convert arrays into stdClass + * @param array|string|bool|int|float|null $value + * @return stdClass|string|bool|int|float|null + */ + public static function toObject( $value ) { + return !is_array( $value ) ? $value : (object)array_map( __METHOD__, $value ); + } + + /** + * @dataProvider provideParse + * @param mixed $value + */ + public function testParse( $value ) { + $expected = self::toObject( $value ); + $json = FormatJson::encode( $expected, false, FormatJson::ALL_OK ); + $this->assertJson( $json ); + + $st = FormatJson::parse( $json ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expected, $st->getValue() ); + + $st = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $value, $st->getValue() ); + } + + /** + * Test data for testParseTryFixing. + * + * Some PHP interpreters use json-c rather than the JSON.org canonical + * parser to avoid being encumbered by the "shall be used for Good, not + * Evil" clause of the JSON.org parser's license. By default, json-c + * parses in a non-strict mode which allows trailing commas for array and + * object delarations among other things, so our JSON_ERROR_SYNTAX rescue + * block is not always triggered. It however isn't lenient in exactly the + * same ways as our TRY_FIXING mode, so the assertions in this test are + * a bit more complicated than they ideally would be: + * + * Optional third argument: true if json-c parses the value without + * intervention, false otherwise. Defaults to true. + * + * Optional fourth argument: expected cannonical JSON serialization of + * json-c parsed result. Defaults to the second argument's value. + */ + public static function provideParseTryFixing() { + return [ + [ "[,]", '[]', false ], + [ "[ , ]", '[]', false ], + [ "[ , }", false ], + [ '[1],', false, true, '[1]' ], + [ "[1,]", '[1]' ], + [ "[1\n,]", '[1]' ], + [ "[1,\n]", '[1]' ], + [ "[1,]\n", '[1]' ], + [ "[1\n,\n]\n", '[1]' ], + [ '["a,",]', '["a,"]' ], + [ "[[1,]\n,[2,\n],[3\n,]]", '[[1],[2],[3]]' ], + // I wish we could parse this, but would need quote parsing + [ '[[1,],[2,],[3,]]', false, true, '[[1],[2],[3]]' ], + [ '[1,,]', false, false, '[1]' ], + ]; + } + + /** + * @dataProvider provideParseTryFixing + * @param string $value + * @param string|bool $expected Expected result with strict parser + * @param bool $jsoncParses Will json-c parse this value without TRY_FIXING? + * @param string|bool $expectedJsonc Expected result with lenient parser + * if different from the strict expectation + */ + public function testParseTryFixing( + $value, $expected, + $jsoncParses = true, $expectedJsonc = null + ) { + // PHP5 results are always expected to have isGood() === false + $expectedGoodStatus = false; + + // Check to see if json parser allows trailing commas + if ( json_decode( '[1,]' ) !== null ) { + // Use json-c specific expected result if provided + $expected = ( $expectedJsonc === null ) ? $expected : $expectedJsonc; + // If json-c parses the value natively, expect isGood() === true + $expectedGoodStatus = $jsoncParses; + } + + $st = FormatJson::parse( $value, FormatJson::TRY_FIXING ); + $this->assertInstanceOf( Status::class, $st ); + if ( $expected === false ) { + $this->assertFalse( $st->isOK(), 'Expected isOK() == false' ); + } else { + $this->assertSame( $expectedGoodStatus, $st->isGood(), + 'Expected isGood() == ' . ( $expectedGoodStatus ? 'true' : 'false' ) + ); + $this->assertTrue( $st->isOK(), 'Expected isOK == true' ); + $val = FormatJson::encode( $st->getValue(), false, FormatJson::ALL_OK ); + $this->assertEquals( $expected, $val ); + } + } + + public static function provideParseErrors() { + return [ + [ 'aaa' ], + [ '{"j": 1 ] }' ], + ]; + } + + /** + * @dataProvider provideParseErrors + * @param mixed $value + */ + public function testParseErrors( $value ) { + $st = FormatJson::parse( $value ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertFalse( $st->isOK() ); + } + + public function provideStripComments() { + return [ + [ '{"a":"b"}', '{"a":"b"}' ], + [ "{\"a\":\"b\"}\n", "{\"a\":\"b\"}\n" ], + [ '/*c*/{"c":"b"}', '{"c":"b"}' ], + [ '{"a":"c"}/*c*/', '{"a":"c"}' ], + [ '/*c//d*/{"c":"b"}', '{"c":"b"}' ], + [ '{/*c*/"c":"b"}', '{"c":"b"}' ], + [ "/*\nc\r\n*/{\"c\":\"b\"}", '{"c":"b"}' ], + [ "//c\n{\"c\":\"b\"}", '{"c":"b"}' ], + [ "//c\r\n{\"c\":\"b\"}", '{"c":"b"}' ], + [ '{"a":"c"}//c', '{"a":"c"}' ], + [ "{\"a-c\"://c\n\"b\"}", '{"a-c":"b"}' ], + [ '{"/*a":"b"}', '{"/*a":"b"}' ], + [ '{"a":"//b"}', '{"a":"//b"}' ], + [ '{"a":"b/*c*/"}', '{"a":"b/*c*/"}' ], + [ "{\"\\\"/*a\":\"b\"}", "{\"\\\"/*a\":\"b\"}" ], + [ '', '' ], + [ '/*c', '' ], + [ '//c', '' ], + [ '"http://example.com"', '"http://example.com"' ], + [ "\0", "\0" ], + [ '"Blåbærsyltetøy"', '"Blåbærsyltetøy"' ], + ]; + } + + /** + * @covers FormatJson::stripComments + * @dataProvider provideStripComments + * @param string $json + * @param string $expect + */ + public function testStripComments( $json, $expect ) { + $this->assertSame( $expect, FormatJson::stripComments( $json ) ); + } + + public function provideParseStripComments() { + return [ + [ '/* blah */true', true ], + [ "// blah \ntrue", true ], + [ '[ "a" , /* blah */ "b" ]', [ 'a', 'b' ] ], + ]; + } + + /** + * @covers FormatJson::parse + * @covers FormatJson::stripComments + * @dataProvider provideParseStripComments + * @param string $json + * @param mixed $expect + */ + public function testParseStripComments( $json, $expect ) { + $st = FormatJson::parse( $json, FormatJson::STRIP_COMMENTS ); + $this->assertInstanceOf( Status::class, $st ); + $this->assertTrue( $st->isGood() ); + $this->assertEquals( $expect, $st->getValue() ); + } + + /** + * Generate a set of test cases for a particular combination of encoder options. + * + * @param array $unescapedGroups List of character groups to leave unescaped + * @return array Arrays of unencoded strings and corresponding encoded strings + */ + private static function getEncodeTestCases( array $unescapedGroups ) { + $groups = [ + 'always' => [ + // Forward slash (always unescaped) + '/' => '/', + + // Control characters + "\0" => '\u0000', + "\x08" => '\b', + "\t" => '\t', + "\n" => '\n', + "\r" => '\r', + "\f" => '\f', + "\x1f" => '\u001f', // representative example + + // Double quotes + '"' => '\"', + + // Backslashes + '\\' => '\\\\', + '\\\\' => '\\\\\\\\', + '\\u00e9' => '\\\u00e9', // security check for Unicode unescaping + + // Line terminators + "\xe2\x80\xa8" => '\u2028', + "\xe2\x80\xa9" => '\u2029', + ], + 'unicode' => [ + "\xc3\xa9" => '\u00e9', + "\xf0\x9d\x92\x9e" => '\ud835\udc9e', // U+1D49E, outside the BMP + ], + 'xmlmeta' => [ + '<' => '\u003C', // JSON_HEX_TAG uses uppercase hex digits + '>' => '\u003E', + '&' => '\u0026', + ], + ]; + + $cases = []; + foreach ( $groups as $name => $rules ) { + $leaveUnescaped = in_array( $name, $unescapedGroups ); + foreach ( $rules as $from => $to ) { + $cases[] = [ $from, '"' . ( $leaveUnescaped ? $from : $to ) . '"' ]; + } + } + + return $cases; + } + + public function provideEmptyJsonKeyStrings() { + return [ + [ + '{"":"foo"}', + '{"":"foo"}', + '' + ], + [ + '{"_empty_":"foo"}', + '{"_empty_":"foo"}', + '_empty_' ], + [ + '{"\u005F\u0065\u006D\u0070\u0074\u0079\u005F":"foo"}', + '{"_empty_":"foo"}', + '_empty_' + ], + [ + '{"_empty_":"bar","":"foo"}', + '{"_empty_":"bar","":"foo"}', + '' + ], + [ + '{"":"bar","_empty_":"foo"}', + '{"":"bar","_empty_":"foo"}', + '_empty_' + ] + ]; + } + + /** + * @covers FormatJson::encode + * @covers FormatJson::decode + * @dataProvider provideEmptyJsonKeyStrings + * @param string $json + * + * Decoding behavior with empty keys can be surprising. + * See https://phabricator.wikimedia.org/T206411 + */ + public function testEmptyJsonKeyArray( $json, $expect, $php71Name ) { + // Decoding to array is consistent across supported PHP versions + $this->assertSame( $expect, FormatJson::encode( + FormatJson::decode( $json, true ) ) ); + + // Decoding to object differs between supported PHP versions + $obj = FormatJson::decode( $json ); + if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { + $this->assertEquals( 'foo', $obj->_empty_ ); + } else { + $this->assertEquals( 'foo', $obj->{$php71Name} ); + } + } +} diff --git a/tests/phpunit/includes/libs/ArrayUtilsTest.php b/tests/phpunit/includes/libs/ArrayUtilsTest.php new file mode 100644 index 0000000000..12b6320564 --- /dev/null +++ b/tests/phpunit/includes/libs/ArrayUtilsTest.php @@ -0,0 +1,308 @@ +assertSame( + ArrayUtils::findLowerBound( + $valueCallback, $valueCount, $comparisonCallback, $target + ), $expected + ); + } + + function provideFindLowerBound() { + $indexValueCallback = function ( $size ) { + return function ( $val ) use ( $size ) { + $this->assertTrue( $val >= 0 ); + $this->assertTrue( $val < $size ); + return $val; + }; + }; + $comparisonCallback = function ( $a, $b ) { + return $a - $b; + }; + + return [ + [ + $indexValueCallback( 0 ), + 0, + $comparisonCallback, + 1, + false, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + -1, + false, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 0, + 0, + ], + [ + $indexValueCallback( 1 ), + 1, + $comparisonCallback, + 1, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + -1, + false, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 0.5, + 0, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1, + 1, + ], + [ + $indexValueCallback( 2 ), + 2, + $comparisonCallback, + 1.5, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 1.5, + 1, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 2, + 2, + ], + [ + $indexValueCallback( 3 ), + 3, + $comparisonCallback, + 3, + 2, + ], + ]; + } + + /** + * @covers ArrayUtils::arrayDiffAssocRecursive + * @dataProvider provideArrayDiffAssocRecursive + */ + function testArrayDiffAssocRecursive( $expected, ...$args ) { + $this->assertEquals( call_user_func_array( + 'ArrayUtils::arrayDiffAssocRecursive', $args + ), $expected ); + } + + function provideArrayDiffAssocRecursive() { + return [ + [ + [], + [], + [], + ], + [ + [], + [], + [], + [], + ], + [ + [ 1 ], + [ 1 ], + [], + ], + [ + [ 1 ], + [ 1 ], + [], + [], + ], + [ + [], + [], + [ 1 ], + ], + [ + [], + [], + [ 1 ], + [ 2 ], + ], + [ + [ '' => 1 ], + [ '' => 1 ], + [], + ], + [ + [], + [], + [ '' => 1 ], + ], + [ + [ 1 ], + [ 1 ], + [ 2 ], + ], + [ + [], + [ 1 ], + [ 2 ], + [ 1 ], + ], + [ + [], + [ 1 ], + [ 1, 2 ], + ], + [ + [ 1 => 1 ], + [ 1 => 1 ], + [ 1 ], + ], + [ + [], + [ 1 => 1 ], + [ 1 ], + [ 1 => 1 ], + ], + [ + [], + [ 1 => 1 ], + [ 1, 1, 1 ], + ], + [ + [], + [ [] ], + [], + ], + [ + [], + [ [ [] ] ], + [], + ], + [ + [ 1, [ 1 ] ], + [ 1, [ 1 ] ], + [], + ], + [ + [ 1 ], + [ 1, [ 1 ] ], + [ 2, [ 1 ] ], + ], + [ + [], + [ 1, [ 1 ] ], + [ 2, [ 1 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 ], + [ 1, [] ], + [ 2 ], + ], + [ + [], + [ 1, [] ], + [ 2 ], + [ 1 ], + ], + [ + [ 1, [ 1 => 2 ] ], + [ 1, [ 1, 2 ] ], + [ 2, [ 1 ] ], + ], + [ + [ 1 ], + [ 1, [ 1, 2 ] ], + [ 2, [ 1 ] ], + [ 2, [ 1 => 2 ] ], + ], + [ + [ 1 => [ 1, 2 ] ], + [ 1, [ 1, 2 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 => [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ 2 ] ], + ], + [ + [ 1 => [ [ 2 ], 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3 ] ] ], + ], + [ + [ 1 => [ 1 => 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3, 0 => 2 ] ] ], + ], + [ + [ 1 => [ 1 => 2 ] ], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1, [ [ 1 => 3 ] ] ], + [ 1 => [ [ 2 ] ] ], + ], + [ + [], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1 => [ 1 => 2, 0 => [ 1 => 3, 0 => 2 ] ], 0 => 1 ], + ], + [ + [], + [ 1, [ [ 2, 3 ], 2 ] ], + [ 1 => [ 1 => 2 ] ], + [ 1 => [ [ 1 => 3 ] ] ], + [ 1 => [ [ 2 ] ] ], + [ 1 ], + ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/CookieTest.php b/tests/phpunit/includes/libs/CookieTest.php new file mode 100644 index 0000000000..e383be9650 --- /dev/null +++ b/tests/phpunit/includes/libs/CookieTest.php @@ -0,0 +1,52 @@ +assertEquals( $expected, $ok, $msg ); + } + + public static function cookieDomains() { + return [ + [ false, "org" ], + [ false, ".org" ], + [ true, "wikipedia.org" ], + [ true, ".wikipedia.org" ], + [ false, "co.uk" ], + [ false, ".co.uk" ], + [ false, "gov.uk" ], + [ false, ".gov.uk" ], + [ true, "supermarket.uk" ], + [ false, "uk" ], + [ false, ".uk" ], + [ false, "127.0.0." ], + [ false, "127." ], + [ false, "127.0.0.1." ], + [ true, "127.0.0.1" ], + [ false, "333.0.0.1" ], + [ true, "example.com" ], + [ false, "example.com." ], + [ true, ".example.com" ], + + [ true, ".example.com", "www.example.com" ], + [ false, "example.com", "www.example.com" ], + [ true, "127.0.0.1", "127.0.0.1" ], + [ false, "127.0.0.1", "localhost" ], + ]; + } + +} diff --git a/tests/phpunit/includes/libs/DeferredStringifierTest.php b/tests/phpunit/includes/libs/DeferredStringifierTest.php new file mode 100644 index 0000000000..c9cdf5831d --- /dev/null +++ b/tests/phpunit/includes/libs/DeferredStringifierTest.php @@ -0,0 +1,54 @@ +newInstanceArgs( $params ); + $this->assertEquals( $expected, (string)$ds ); + } + + public static function provideToString() { + return [ + // No args + [ + [ + function () { + return 'foo'; + } + ], + 'foo' + ], + // Has args + [ + [ + function ( $i ) { + return $i; + }, + 'bar' + ], + 'bar' + ], + ]; + } + + /** + * Verify that the callback is not called if + * it is never converted to a string + */ + public function testCallbackNotCalled() { + $ds = new DeferredStringifier( function () { + throw new Exception( 'This should not be reached!' ); + } ); + // No exception was thrown + $this->assertTrue( true ); + } +} diff --git a/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php b/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php new file mode 100644 index 0000000000..1b3397c12a --- /dev/null +++ b/tests/phpunit/includes/libs/DnsSrvDiscovererTest.php @@ -0,0 +1,144 @@ +pickServer( $params ); + + $this->assertEquals( $expected, $record ); + } + + public static function provideRecords() { + return [ + [ + [ // record list + [ + 'target' => 'conf03.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf02.example.net', + 'port' => 'SRV', + 'pri' => 1, + 'weight' => 1, + ], + [ + 'target' => 'conf01.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + ], // selected record + [ + 'target' => 'conf03.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ] + ], + [ + [ // record list + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ], + [ + 'target' => 'conf01.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf04.example.net', + 'port' => 'SRV', + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 'SRV', + 'pri' => 3, + 'weight' => 1, + ], + ], // selected record + [ + 'target' => 'conf03or2.example.net', + 'port' => 'SRV', + 'pri' => 0, + 'weight' => 1, + ] + ], + ]; + } + + public function testRemoveServer() { + $dsd = new DnsSrvDiscoverer( 'localhost' ); + + $servers = [ + [ + 'target' => 'conf01.example.net', + 'port' => 35, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf04.example.net', + 'port' => 74, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 77, + 'pri' => 3, + 'weight' => 1, + ], + ]; + $server = $servers[1]; + + $expected = [ + [ + 'target' => 'conf01.example.net', + 'port' => 35, + 'pri' => 2, + 'weight' => 1, + ], + [ + 'target' => 'conf05.example.net', + 'port' => 77, + 'pri' => 3, + 'weight' => 1, + ], + ]; + + $this->assertEquals( + $expected, + $dsd->removeServer( $server, $servers ), + "Correct server removed" + ); + $this->assertEquals( + $expected, + $dsd->removeServer( $server, $servers ), + "Nothing to remove" + ); + } +} diff --git a/tests/phpunit/includes/libs/EasyDeflateTest.php b/tests/phpunit/includes/libs/EasyDeflateTest.php new file mode 100644 index 0000000000..da39d48d90 --- /dev/null +++ b/tests/phpunit/includes/libs/EasyDeflateTest.php @@ -0,0 +1,64 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + * + */ + +/** + * @covers EasyDeflate + */ +class EasyDeflateTest extends PHPUnit\Framework\TestCase { + + public function provideIsDeflated() { + return [ + [ 'rawdeflate,S8vPT0osAgA=', true ], + [ 'abcdefghijklmnopqrstuvwxyz', false ], + ]; + } + + /** + * @dataProvider provideIsDeflated + */ + public function testIsDeflated( $data, $expected ) { + $actual = EasyDeflate::isDeflated( $data ); + $this->assertSame( $expected, $actual ); + } + + public function provideInflate() { + return [ + [ 'rawdeflate,S8vPT0osAgA=', true, 'foobar' ], + // Fails base64_decode + [ 'rawdeflate,🌻', false, 'easydeflate-invaliddeflate' ], + // Fails gzinflate + [ 'rawdeflate,S8vPT0dfdAgB=', false, 'easydeflate-invaliddeflate' ], + ]; + } + + /** + * @dataProvider provideInflate + */ + public function testInflate( $data, $ok, $value ) { + $actual = EasyDeflate::inflate( $data ); + if ( $ok ) { + $this->assertTrue( $actual->isOK() ); + $this->assertSame( $value, $actual->getValue() ); + } else { + $this->assertFalse( $actual->isOK() ); + $this->assertTrue( $actual->hasMessage( $value ) ); + } + } +} diff --git a/tests/phpunit/includes/libs/GenericArrayObjectTest.php b/tests/phpunit/includes/libs/GenericArrayObjectTest.php new file mode 100644 index 0000000000..3be2b06465 --- /dev/null +++ b/tests/phpunit/includes/libs/GenericArrayObjectTest.php @@ -0,0 +1,279 @@ + + */ +abstract class GenericArrayObjectTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * Returns objects that can serve as elements in the concrete + * GenericArrayObject deriving class being tested. + * + * @since 1.20 + * + * @return array + */ + abstract public function elementInstancesProvider(); + + /** + * Returns the name of the concrete class being tested. + * + * @since 1.20 + * + * @return string + */ + abstract public function getInstanceClass(); + + /** + * Provides instances of the concrete class being tested. + * + * @since 1.20 + * + * @return array + */ + public function instanceProvider() { + $instances = []; + + foreach ( $this->elementInstancesProvider() as $elementInstances ) { + $instances[] = $this->getNew( $elementInstances[0] ); + } + + return $this->arrayWrap( $instances ); + } + + /** + * @since 1.20 + * + * @param array $elements + * + * @return GenericArrayObject + */ + protected function getNew( array $elements = [] ) { + $class = $this->getInstanceClass(); + + return new $class( $elements ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::__construct + */ + public function testConstructor( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( count( $elements ), $arrayObject->count() ); + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::isEmpty + */ + public function testIsEmpty( array $elements ) { + $arrayObject = $this->getNew( $elements ); + + $this->assertEquals( $elements === [], $arrayObject->isEmpty() ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.20 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::offsetUnset + */ + public function testUnset( GenericArrayObject $list ) { + if ( $list->isEmpty() ) { + $this->assertTrue( true ); // We cannot test unset if there are no elements + } else { + $offset = $list->getIterator()->key(); + $count = $list->count(); + $list->offsetUnset( $offset ); + $this->assertEquals( $count - 1, $list->count() ); + } + + if ( !$list->isEmpty() ) { + $offset = $list->getIterator()->key(); + $count = $list->count(); + unset( $list[$offset] ); + $this->assertEquals( $count - 1, $list->count() ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * + * @covers GenericArrayObject::append + */ + public function testAppend( array $elements ) { + $list = $this->getNew(); + + $listSize = count( $elements ); + + foreach ( $elements as $element ) { + $list->append( $element ); + } + + $this->assertEquals( $listSize, $list->count() ); + + $list = $this->getNew(); + + foreach ( $elements as $element ) { + $list[] = $element; + } + + $this->assertEquals( $listSize, $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->append( $element ); + } ); + } + + /** + * @since 1.20 + * + * @param callable $function + */ + protected function checkTypeChecks( $function ) { + $excption = null; + $list = $this->getNew(); + + $elementClass = $list->getObjectType(); + + foreach ( [ 42, 'foo', [], new stdClass(), 4.2 ] as $element ) { + $validValid = $element instanceof $elementClass; + + try { + call_user_func( $function, $list, $element ); + $valid = true; + } catch ( InvalidArgumentException $exception ) { + $valid = false; + } + + $this->assertEquals( + $validValid, + $valid, + 'Object of invalid type got successfully added to a GenericArrayObject' + ); + } + } + + /** + * @dataProvider elementInstancesProvider + * + * @since 1.20 + * + * @param array $elements + * @covers GenericArrayObject::getObjectType + * @covers GenericArrayObject::offsetSet + */ + public function testOffsetSet( array $elements ) { + if ( $elements === [] ) { + $this->assertTrue( true ); + + return; + } + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 42, $element ); + $this->assertEquals( $element, $list->offsetGet( 42 ) ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list['oHai'] = $element; + $this->assertEquals( $element, $list['oHai'] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( 9001, $element ); + $this->assertEquals( $element, $list[9001] ); + + $list = $this->getNew(); + + $element = reset( $elements ); + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[0] ); + + $list = $this->getNew(); + $offset = 0; + + foreach ( $elements as $element ) { + $list->offsetSet( null, $element ); + $this->assertEquals( $element, $list[$offset++] ); + } + + $this->assertEquals( count( $elements ), $list->count() ); + + $this->checkTypeChecks( function ( GenericArrayObject $list, $element ) { + $list->offsetSet( mt_rand(), $element ); + } ); + } + + /** + * @dataProvider instanceProvider + * + * @since 1.21 + * + * @param GenericArrayObject $list + * + * @covers GenericArrayObject::getSerializationData + * @covers GenericArrayObject::serialize + * @covers GenericArrayObject::unserialize + */ + public function testSerialization( GenericArrayObject $list ) { + $serialization = serialize( $list ); + $copy = unserialize( $serialization ); + + $this->assertEquals( $serialization, serialize( $copy ) ); + $this->assertEquals( count( $list ), count( $copy ) ); + + $list = $list->getArrayCopy(); + $copy = $copy->getArrayCopy(); + + $this->assertArrayEquals( $list, $copy, true, true ); + } +} diff --git a/tests/phpunit/includes/libs/HashRingTest.php b/tests/phpunit/includes/libs/HashRingTest.php new file mode 100644 index 0000000000..acaeb02558 --- /dev/null +++ b/tests/phpunit/includes/libs/HashRingTest.php @@ -0,0 +1,327 @@ + 3, 's2' => 10, 's3' => 2, 's4' => 10, 's5' => 2, 's6' => 3 ]; + $ring = new HashRing( $map, 'md5' ); + + $serialized = serialize( $ring ); + $ringRemade = unserialize( $serialized ); + + for ( $i = 0; $i < 100; $i++ ) { + $this->assertEquals( + $ring->getLocation( "hello$i" ), + $ringRemade->getLocation( "hello$i" ), + 'Items placed at proper locations' + ); + } + } + + public function testHashRingMapping() { + // SHA-1 based and weighted + $ring = new HashRing( + [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3, 's7' => 0 ], + 'sha1' + ); + + $this->assertEquals( + [ 's1' => 1, 's2' => 1, 's3' => 2, 's4' => 2, 's5' => 2, 's6' => 3 ], + $ring->getLocationWeights(), + 'Normalized location weights' + ); + + $locations = []; + for ( $i = 0; $i < 25; $i++ ) { + $locations[ "hello$i"] = $ring->getLocation( "hello$i" ); + } + $expectedLocations = [ + "hello0" => "s4", + "hello1" => "s6", + "hello2" => "s3", + "hello3" => "s6", + "hello4" => "s6", + "hello5" => "s4", + "hello6" => "s3", + "hello7" => "s4", + "hello8" => "s3", + "hello9" => "s3", + "hello10" => "s3", + "hello11" => "s5", + "hello12" => "s4", + "hello13" => "s5", + "hello14" => "s2", + "hello15" => "s5", + "hello16" => "s6", + "hello17" => "s5", + "hello18" => "s1", + "hello19" => "s1", + "hello20" => "s6", + "hello21" => "s5", + "hello22" => "s3", + "hello23" => "s4", + "hello24" => "s1" + ]; + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + + $locations = []; + for ( $i = 0; $i < 5; $i++ ) { + $locations[ "hello$i"] = $ring->getLocations( "hello$i", 2 ); + } + + $expectedLocations = [ + "hello0" => [ "s4", "s5" ], + "hello1" => [ "s6", "s5" ], + "hello2" => [ "s3", "s1" ], + "hello3" => [ "s6", "s5" ], + "hello4" => [ "s6", "s3" ], + ]; + $this->assertEquals( $expectedLocations, $locations, 'Items placed at proper locations' ); + } + + /** + * @dataProvider providor_getHashLocationWeights + */ + public function testHashRingRatios( $locations, $expectedHits ) { + $ring = new HashRing( $locations, 'whirlpool' ); + + $locationStats = array_fill_keys( array_keys( $locations ), 0 ); + for ( $i = 0; $i < 10000; ++$i ) { + ++$locationStats[$ring->getLocation( "key-$i" )]; + } + $this->assertEquals( $expectedHits, $locationStats ); + } + + public static function providor_getHashLocationWeights() { + return [ + [ + [ 'big' => 10, 'medium' => 5, 'small' => 1 ], + [ 'big' => 6037, 'medium' => 3314, 'small' => 649 ] + ] + ]; + } + + /** + * @dataProvider providor_getHashLocationWeights2 + */ + public function testHashRingRatios2( $locations, $expected ) { + $ring = new HashRing( $locations, 'sha1' ); + $locationStats = array_fill_keys( array_keys( $locations ), 0 ); + for ( $i = 0; $i < 1000; ++$i ) { + foreach ( $ring->getLocations( "key-$i", 3 ) as $location ) { + ++$locationStats[$location]; + } + } + $this->assertEquals( $expected, $locationStats ); + } + + public static function providor_getHashLocationWeights2() { + return [ + [ + [ 'big1' => 10, 'big2' => 10, 'big3' => 10, 'small1' => 1, 'small2' => 1 ], + [ 'big1' => 929, 'big2' => 899, 'big3' => 887, 'small1' => 143, 'small2' => 142 ] + ] + ]; + } + + public function testHashRingEjection() { + $map = [ 's1' => 5, 's2' => 5, 's3' => 10, 's4' => 10, 's5' => 5, 's6' => 5 ]; + $ring = new HashRing( $map, 'md5' ); + + $ring->ejectFromLiveRing( 's3', 30 ); + $ring->ejectFromLiveRing( 's6', 15 ); + + $this->assertEquals( + [ 's1' => 5, 's2' => 5, 's4' => 10, 's5' => 5 ], + $ring->getLiveLocationWeights(), + 'Live location weights' + ); + + for ( $i = 0; $i < 100; ++$i ) { + $key = "key-$i"; + + $this->assertNotEquals( 's3', $ring->getLiveLocation( $key ), 'ejected' ); + $this->assertNotEquals( 's6', $ring->getLiveLocation( $key ), 'ejected' ); + + if ( !in_array( $ring->getLocation( $key ), [ 's3', 's6' ], true ) ) { + $this->assertEquals( + $ring->getLocation( $key ), + $ring->getLiveLocation( $key ), + "Live ring otherwise matches (#$i)" + ); + $this->assertEquals( + $ring->getLocations( $key, 1 ), + $ring->getLiveLocations( $key, 1 ), + "Live ring otherwise matches (#$i)" + ); + } + } + } + + public function testHashRingCollision() { + $ring1 = new HashRing( [ 0 => 1, 6497 => 1 ] ); + $ring2 = new HashRing( [ 6497 => 1, 0 => 1 ] ); + + for ( $i = 0; $i < 100; ++$i ) { + $this->assertEquals( $ring1->getLocation( $i ), $ring2->getLocation( $i ) ); + } + } + + public function testHashRingKetamaMode() { + // Same as https://github.com/RJ/ketama/blob/master/ketama.servers + $map = [ + '10.0.1.1:11211' => 600, + '10.0.1.2:11211' => 300, + '10.0.1.3:11211' => 200, + '10.0.1.4:11211' => 350, + '10.0.1.5:11211' => 1000, + '10.0.1.6:11211' => 800, + '10.0.1.7:11211' => 950, + '10.0.1.8:11211' => 100 + ]; + $ring = new HashRing( $map, 'md5' ); + $wrapper = \Wikimedia\TestingAccessWrapper::newFromObject( $ring ); + + $ketama_test = function ( $count ) use ( $wrapper ) { + $baseRing = $wrapper->baseRing; + + $lines = []; + for ( $key = 0; $key < $count; ++$key ) { + $location = $wrapper->getLocation( $key ); + + $itemPos = $wrapper->getItemPosition( $key ); + $nodeIndex = $wrapper->findNodeIndexForPosition( $itemPos, $baseRing ); + $nodePos = $baseRing[$nodeIndex][HashRing::KEY_POS]; + + $lines[] = sprintf( "%u %u %s\n", $itemPos, $nodePos, $location ); + } + + return "\n" . implode( '', $lines ); + }; + + // Known correct values generated from C code: + // https://github.com/RJ/ketama/blob/master/libketama/ketama_test.c + $expected = <<assertEquals( $expected, $ketama_test( 100 ), 'Ketama mode (diff check)' ); + + // Hash of known correct values from C code + $this->assertEquals( + 'c69ac9eb7a8a630c0cded201cefeaace', + md5( $ketama_test( 1e5 ) ), + 'Ketama mode (large, MD5 check)' + ); + + // Slower, full upstream MD5 check, manually verified 3/21/2018 + // $this->assertEquals( '5672b131391f5aa2b280936aec1eea74', md5( $ketama_test( 1e6 ) ) ); + } +} diff --git a/tests/phpunit/includes/libs/HtmlArmorTest.php b/tests/phpunit/includes/libs/HtmlArmorTest.php new file mode 100644 index 0000000000..c5e87e4e2a --- /dev/null +++ b/tests/phpunit/includes/libs/HtmlArmorTest.php @@ -0,0 +1,55 @@ +some html!' ] + ]; + } + + /** + * @dataProvider provideConstructor + */ + public function testConstructor( $value ) { + $this->assertInstanceOf( HtmlArmor::class, new HtmlArmor( $value ) ); + } + + public static function provideGetHtml() { + return [ + [ + 'foobar', + 'foobar', + ], + [ + '', + '<script>alert("evil!");</script>', + ], + [ + new HtmlArmor( '' ), + '', + ], + [ + new HtmlArmor( null ), + null, + ] + ]; + } + + /** + * @dataProvider provideGetHtml + */ + public function testGetHtml( $input, $expected ) { + $this->assertEquals( + $expected, + HtmlArmor::getHtml( $input ) + ); + } +} diff --git a/tests/phpunit/includes/libs/IEUrlExtensionTest.php b/tests/phpunit/includes/libs/IEUrlExtensionTest.php new file mode 100644 index 0000000000..e04b2e21bf --- /dev/null +++ b/tests/phpunit/includes/libs/IEUrlExtensionTest.php @@ -0,0 +1,42 @@ +assertEquals( + $expected, + IEUrlExtension::findIE6Extension( $url ), + $message + ); + } +} diff --git a/tests/phpunit/includes/libs/IPTest.php b/tests/phpunit/includes/libs/IPTest.php new file mode 100644 index 0000000000..9ec53c00c7 --- /dev/null +++ b/tests/phpunit/includes/libs/IPTest.php @@ -0,0 +1,673 @@ +assertFalse( IP::isIPAddress( $val ), $desc ); + } + + /** + * Provide a list of things that aren't IP addresses + */ + public function provideInvalidIPs() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Garbage IP string' ], + [ ':', 'Single ":" is not an IP' ], + [ '2001:0DB8::A:1::1', 'IPv6 with a double :: occurrence' ], + [ '2001:0DB8::A:1::', 'IPv6 with a double :: occurrence, last at end' ], + [ '::2001:0DB8::5:1', 'IPv6 with a double :: occurrence, firt at beginning' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + [ 'fc:100:300', 'IPv6 with only 3 words' ], + ]; + } + + /** + * @covers IP::isIPAddress + */ + public function testisIPAddress() { + $this->assertTrue( IP::isIPAddress( '::' ), 'RFC 4291 IPv6 Unspecified Address' ); + $this->assertTrue( IP::isIPAddress( '::1' ), 'RFC 4291 IPv6 Loopback Address' ); + $this->assertTrue( IP::isIPAddress( '74.24.52.13/20' ), 'IPv4 range' ); + $this->assertTrue( IP::isIPAddress( 'fc:100:a:d:1:e:ac:0/24' ), 'IPv6 range' ); + $this->assertTrue( IP::isIPAddress( 'fc::100:a:d:1:e:ac/96' ), 'IPv6 range with "::"' ); + + $validIPs = [ 'fc:100::', 'fc:100:a:d:1:e:ac::', 'fc::100', '::fc:100:a:d:1:e:ac', + '::fc', 'fc::100:a:d:1:e:ac', 'fc:100:a:d:1:e:ac:0', '124.24.52.13', '1.24.52.13' ]; + foreach ( $validIPs as $ip ) { + $this->assertTrue( IP::isIPAddress( $ip ), "$ip is a valid IP address" ); + } + } + + /** + * @covers IP::isIPv6 + */ + public function testisIPv6() { + $this->assertFalse( IP::isIPv6( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isIPv6( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isIPv6( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertFalse( IP::isIPv6( 'fc:100:a:d:1:e:ac:0::' ), 'IPv6 with 8 words ending with "::"' ); + $this->assertFalse( + IP::isIPv6( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + + $this->assertFalse( IP::isIPv6( ':::' ) ); + $this->assertFalse( IP::isIPv6( '::0:' ), 'IPv6 ending in a lone ":"' ); + + $this->assertTrue( IP::isIPv6( '::' ), 'IPv6 zero address' ); + $this->assertTrue( IP::isIPv6( '::0' ) ); + $this->assertTrue( IP::isIPv6( '::fc' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e' ) ); + $this->assertTrue( IP::isIPv6( '::fc:100:a:d:1:e:ac' ) ); + + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( '::fc:100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertFalse( IP::isIPv6( ':fc::100' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc::100:' ), 'IPv6 ending with lone ":"' ); + $this->assertFalse( IP::isIPv6( 'fc:::100' ), 'IPv6 with ":::" in the middle' ); + + $this->assertTrue( IP::isIPv6( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d' ), 'IPv6 with "::" and 4 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isIPv6( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + $this->assertTrue( IP::isIPv6( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isIPv6( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0' ), 'IPv6 with "::" and 8 words' ); + $this->assertFalse( IP::isIPv6( 'fc::100:a:d:1:e:ac:0:1' ), 'IPv6 with 9 words' ); + + $this->assertTrue( IP::isIPv6( 'fc:100:a:d:1:e:ac:0' ) ); + } + + /** + * @covers IP::isIPv4 + * @dataProvider provideInvalidIPv4Addresses + */ + public function testisNotIPv4( $bogusIP, $desc ) { + $this->assertFalse( IP::isIPv4( $bogusIP ), $desc ); + } + + public function provideInvalidIPv4Addresses() { + return [ + [ false, 'Boolean false is not an IP' ], + [ true, 'Boolean true is not an IP' ], + [ '', 'Empty string is not an IP' ], + [ 'abc', 'Letters are not an IP' ], + [ ':', 'A colon is not an IP' ], + [ '124.24.52', 'IPv4 not enough quads' ], + [ '24.324.52.13', 'IPv4 out of range' ], + [ '.24.52.13', 'IPv4 starts with period' ], + ]; + } + + /** + * @covers IP::isIPv4 + * @dataProvider provideValidIPv4Address + */ + public function testIsIPv4( $ip, $desc ) { + $this->assertTrue( IP::isIPv4( $ip ), $desc ); + } + + /** + * Provide some IPv4 addresses and ranges + */ + public function provideValidIPv4Address() { + return [ + [ '124.24.52.13', 'Valid IPv4 address' ], + [ '1.24.52.13', 'Another valid IPv4 address' ], + [ '74.24.52.13/20', 'An IPv4 range' ], + ]; + } + + /** + * @covers IP::isValid + */ + public function testValidIPs() { + foreach ( range( 0, 255 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv4 address" ); + } + } + foreach ( range( 0x0, 0xFFFF, 0xF ) as $i ) { + $a = sprintf( "%04x", $i ); + $b = sprintf( "%03x", $i ); + $c = sprintf( "%02x", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertTrue( IP::isValid( $ip ), "$ip is a valid IPv6 address" ); + } + } + // test with some abbreviations + $this->assertFalse( IP::isValid( ':fc:100::' ), 'IPv6 starting with lone ":"' ); + $this->assertFalse( IP::isValid( 'fc:100:::' ), 'IPv6 ending with a ":::"' ); + $this->assertFalse( IP::isValid( 'fc:300' ), 'IPv6 with only 2 words' ); + $this->assertFalse( IP::isValid( 'fc:100:300' ), 'IPv6 with only 3 words' ); + + $this->assertTrue( IP::isValid( 'fc:100::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e::' ) ); + $this->assertTrue( IP::isValid( 'fc:100:a:d:1:e:ac::' ) ); + + $this->assertTrue( IP::isValid( 'fc::100' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a' ), 'IPv6 with "::" and 3 words' ); + $this->assertTrue( IP::isValid( '2001::df' ), 'IPv6 with "::" and 2 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( '2001:5c0:1400:a::df:2' ), 'IPv6 with "::" and 6 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1' ), 'IPv6 with "::" and 5 words' ); + $this->assertTrue( IP::isValid( 'fc::100:a:d:1:e:ac' ), 'IPv6 with "::" and 7 words' ); + + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0::' ), + 'IPv6 with 8 words ending with "::"' + ); + $this->assertFalse( + IP::isValid( 'fc:100:a:d:1:e:ac:0:1::' ), + 'IPv6 with 9 words ending with "::"' + ); + } + + /** + * @covers IP::isValid + */ + public function testInvalidIPs() { + // Out of range... + foreach ( range( 256, 999 ) as $i ) { + $a = sprintf( "%03d", $i ); + $b = sprintf( "%02d", $i ); + $c = sprintf( "%01d", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f.$f.$f.$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv4 address" ); + } + } + foreach ( range( 'g', 'z' ) as $i ) { + $a = sprintf( "%04s", $i ); + $b = sprintf( "%03s", $i ); + $c = sprintf( "%02s", $i ); + foreach ( array_unique( [ $a, $b, $c ] ) as $f ) { + $ip = "$f:$f:$f:$f:$f:$f:$f:$f"; + $this->assertFalse( IP::isValid( $ip ), "$ip is not a valid IPv6 address" ); + } + } + // Have CIDR + $ipCIDRs = [ + '212.35.31.121/32', + '212.35.31.121/18', + '212.35.31.121/24', + '::ff:d:321:5/96', + 'ff::d3:321:5/116', + 'c:ff:12:1:ea:d:321:5/120', + ]; + foreach ( $ipCIDRs as $i ) { + $this->assertFalse( IP::isValid( $i ), + "$i is an invalid IP address because it is a range" ); + } + // Incomplete/garbage + $invalid = [ + 'www.xn--var-xla.net', + '216.17.184.G', + '216.17.184.1.', + '216.17.184', + '216.17.184.', + '256.17.184.1' + ]; + foreach ( $invalid as $i ) { + $this->assertFalse( IP::isValid( $i ), "$i is an invalid IP address" ); + } + } + + /** + * Provide some valid IP ranges + */ + public function provideValidRanges() { + return [ + [ '116.17.184.5/32' ], + [ '0.17.184.5/30' ], + [ '16.17.184.1/24' ], + [ '30.242.52.14/1' ], + [ '10.232.52.13/8' ], + [ '30.242.52.14/0' ], + [ '::e:f:2001/96' ], + [ '::c:f:2001/128' ], + [ '::10:f:2001/70' ], + [ '::fe:f:2001/1' ], + [ '::6d:f:2001/8' ], + [ '::fe:f:2001/0' ], + ]; + } + + /** + * @covers IP::isValidRange + * @dataProvider provideValidRanges + */ + public function testValidRanges( $range ) { + $this->assertTrue( IP::isValidRange( $range ), "$range is a valid IP range" ); + } + + /** + * @covers IP::isValidRange + * @dataProvider provideInvalidRanges + */ + public function testInvalidRanges( $invalid ) { + $this->assertFalse( IP::isValidRange( $invalid ), "$invalid is not a valid IP range" ); + } + + public function provideInvalidRanges() { + return [ + [ '116.17.184.5/33' ], + [ '0.17.184.5/130' ], + [ '16.17.184.1/-1' ], + [ '10.232.52.13/*' ], + [ '7.232.52.13/ab' ], + [ '11.232.52.13/' ], + [ '::e:f:2001/129' ], + [ '::c:f:2001/228' ], + [ '::10:f:2001/-1' ], + [ '::6d:f:2001/*' ], + [ '::86:f:2001/ab' ], + [ '::23:f:2001/' ], + ]; + } + + /** + * @covers IP::sanitizeIP + * @dataProvider provideSanitizeIP + */ + public function testSanitizeIP( $expected, $input ) { + $result = IP::sanitizeIP( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testSanitizeIP() + */ + public static function provideSanitizeIP() { + return [ + [ '0.0.0.0', '0.0.0.0' ], + [ '0.0.0.0', '00.00.00.00' ], + [ '0.0.0.0', '000.000.000.000' ], + [ '0.0.0.0/24', '000.000.000.000/24' ], + [ '141.0.11.253', '141.000.011.253' ], + [ '1.2.4.5', '1.2.4.5' ], + [ '1.2.4.5', '01.02.04.05' ], + [ '1.2.4.5', '001.002.004.005' ], + [ '10.0.0.1', '010.0.000.1' ], + [ '80.72.250.4', '080.072.250.04' ], + [ 'Foo.1000.00', 'Foo.1000.00' ], + [ 'Bar.01', 'Bar.01' ], + [ 'Bar.010', 'Bar.010' ], + [ null, '' ], + [ null, ' ' ] + ]; + } + + /** + * @covers IP::toHex + * @dataProvider provideToHex + */ + public function testToHex( $expected, $input ) { + $result = IP::toHex( $input ); + $this->assertTrue( $result === false || is_string( $result ) ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testToHex() + */ + public static function provideToHex() { + return [ + [ '00000001', '0.0.0.1' ], + [ '01020304', '1.2.3.4' ], + [ '7F000001', '127.0.0.1' ], + [ '80000000', '128.0.0.0' ], + [ 'DEADCAFE', '222.173.202.254' ], + [ 'FFFFFFFF', '255.255.255.255' ], + [ '8D000BFD', '141.000.11.253' ], + [ false, 'IN.VA.LI.D' ], + [ 'v6-00000000000000000000000000000001', '::1' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:0db8:85a3:0000:0000:8a2e:0370:7334' ], + [ 'v6-20010DB885A3000000008A2E03707334', '2001:db8:85a3::8a2e:0370:7334' ], + [ false, 'IN:VA::LI:D' ], + [ false, ':::1' ] + ]; + } + + /** + * @covers IP::isPublic + * @dataProvider provideIsPublic + */ + public function testIsPublic( $expected, $input ) { + $result = IP::isPublic( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Provider for IP::testIsPublic() + */ + public static function provideIsPublic() { + return [ + [ false, 'fc00::3' ], # RFC 4193 (local) + [ false, 'fc00::ff' ], # RFC 4193 (local) + [ false, '127.1.2.3' ], # loopback + [ false, '::1' ], # loopback + [ false, 'fe80::1' ], # link-local + [ false, '169.254.1.1' ], # link-local + [ false, '10.0.0.1' ], # RFC 1918 (private) + [ false, '172.16.0.1' ], # RFC 1918 (private) + [ false, '192.168.0.1' ], # RFC 1918 (private) + [ true, '2001:5c0:1000:a::133' ], # public + [ true, 'fc::3' ], # public + [ true, '00FC::' ] # public + ]; + } + + // Private wrapper used to test CIDR Parsing. + private function assertFalseCIDR( $CIDR, $msg = '' ) { + $ff = [ false, false ]; + $this->assertEquals( $ff, IP::parseCIDR( $CIDR ), $msg ); + } + + // Private wrapper to test network shifting using only dot notation + private function assertNet( $expected, $CIDR ) { + $parse = IP::parseCIDR( $CIDR ); + $this->assertEquals( $expected, long2ip( $parse[0] ), "network shifting $CIDR" ); + } + + /** + * @covers IP::hexToQuad + * @dataProvider provideIPsAndHexes + */ + public function testHexToQuad( $ip, $hex ) { + $this->assertEquals( $ip, IP::hexToQuad( $hex ) ); + } + + /** + * Provide some IP addresses and their equivalent hex representations + */ + public function provideIPsandHexes() { + return [ + [ '0.0.0.1', '00000001' ], + [ '255.0.0.0', 'FF000000' ], + [ '255.255.255.255', 'FFFFFFFF' ], + [ '10.188.222.255', '0ABCDEFF' ], + // hex not left-padded... + [ '0.0.0.0', '0' ], + [ '0.0.0.1', '1' ], + [ '0.0.0.255', 'FF' ], + [ '0.0.255.0', 'FF00' ], + ]; + } + + /** + * @covers IP::hexToOctet + * @dataProvider provideOctetsAndHexes + */ + public function testHexToOctet( $octet, $hex ) { + $this->assertEquals( $octet, IP::hexToOctet( $hex ) ); + } + + /** + * Provide some hex and octet representations of the same IPs + */ + public function provideOctetsAndHexes() { + return [ + [ '0:0:0:0:0:0:0:1', '00000000000000000000000000000001' ], + [ '0:0:0:0:0:0:FF:3', '00000000000000000000000000FF0003' ], + [ '0:0:0:0:0:0:FF00:6', '000000000000000000000000FF000006' ], + [ '0:0:0:0:0:0:FCCF:FAFF', '000000000000000000000000FCCFFAFF' ], + [ 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' ], + // hex not left-padded... + [ '0:0:0:0:0:0:0:0', '0' ], + [ '0:0:0:0:0:0:0:1', '1' ], + [ '0:0:0:0:0:0:0:FF', 'FF' ], + [ '0:0:0:0:0:0:0:FFD0', 'FFD0' ], + [ '0:0:0:0:0:0:FA00:0', 'FA000000' ], + [ '0:0:0:0:0:0:FCCF:FAFF', 'FCCFFAFF' ], + ]; + } + + /** + * IP::parseCIDR() returns an array containing a signed IP address + * representing the network mask and the bit mask. + * @covers IP::parseCIDR + */ + public function testCIDRParsing() { + $this->assertFalseCIDR( '192.0.2.0', "missing mask" ); + $this->assertFalseCIDR( '192.0.2.0/', "missing bitmask" ); + + // Verify if statement + $this->assertFalseCIDR( '256.0.0.0/32', "invalid net" ); + $this->assertFalseCIDR( '192.0.2.0/AA', "mask not numeric" ); + $this->assertFalseCIDR( '192.0.2.0/-1', "mask < 0" ); + $this->assertFalseCIDR( '192.0.2.0/33', "mask > 32" ); + + // Check internal logic + # 0 mask always result in array(0,0) + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '192.0.0.2/0' ) ); + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '0.0.0.0/0' ) ); + $this->assertEquals( [ 0, 0 ], IP::parseCIDR( '255.255.255.255/0' ) ); + + // @todo FIXME: Add more tests. + + # This part test network shifting + $this->assertNet( '192.0.0.0', '192.0.0.2/24' ); + $this->assertNet( '192.168.5.0', '192.168.5.13/24' ); + $this->assertNet( '10.0.0.160', '10.0.0.161/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/28' ); + $this->assertNet( '10.0.0.0', '10.0.0.3/30' ); + $this->assertNet( '10.0.0.4', '10.0.0.4/30' ); + $this->assertNet( '172.17.32.0', '172.17.35.48/21' ); + $this->assertNet( '10.128.0.0', '10.135.0.0/9' ); + $this->assertNet( '134.0.0.0', '134.0.5.1/8' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeOnValidIp() { + $this->assertEquals( '192.0.2.152', IP::canonicalize( '192.0.2.152' ), + 'Canonicalization of a valid IP returns it unchanged' ); + } + + /** + * @covers IP::canonicalize + */ + public function testIPCanonicalizeMappedAddress() { + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::ffff:192.0.2.152' ) + ); + $this->assertEquals( + '192.0.2.152', + IP::canonicalize( '::192.0.2.152' ) + ); + } + + /** + * Issues there are most probably from IP::toHex() or IP::parseRange() + * @covers IP::isInRange + * @dataProvider provideIPsAndRanges + */ + public function testIPIsInRange( $expected, $addr, $range, $message = '' ) { + $this->assertEquals( + $expected, + IP::isInRange( $addr, $range ), + $message + ); + } + + /** Provider for testIPIsInRange() */ + public static function provideIPsAndRanges() { + # Format: (expected boolean, address, range, optional message) + return [ + # IPv4 + [ true, '192.0.2.0', '192.0.2.0/24', 'Network address' ], + [ true, '192.0.2.77', '192.0.2.0/24', 'Simple address' ], + [ true, '192.0.2.255', '192.0.2.0/24', 'Broadcast address' ], + + [ false, '0.0.0.0', '192.0.2.0/24' ], + [ false, '255.255.255', '192.0.2.0/24' ], + + # IPv6 + [ false, '::1', '2001:DB8::/32' ], + [ false, '::', '2001:DB8::/32' ], + [ false, 'FE80::1', '2001:DB8::/32' ], + + [ true, '2001:DB8::', '2001:DB8::/32' ], + [ true, '2001:0DB8::', '2001:DB8::/32' ], + [ true, '2001:DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8::1', '2001:DB8::/32' ], + [ true, '2001:0DB8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF', + '2001:DB8::/32' ], + + [ false, '2001:0DB8:F::', '2001:DB8::/96' ], + ]; + } + + /** + * @covers IP::splitHostAndPort() + * @dataProvider provideSplitHostAndPort + */ + public function testSplitHostAndPort( $expected, $input, $description ) { + $this->assertEquals( $expected, IP::splitHostAndPort( $input ), $description ); + } + + /** + * Provider for IP::splitHostAndPort() + */ + public static function provideSplitHostAndPort() { + return [ + [ false, '[', 'Unclosed square bracket' ], + [ false, '[::', 'Unclosed square bracket 2' ], + [ [ '::', false ], '::', 'Bare IPv6 0' ], + [ [ '::1', false ], '::1', 'Bare IPv6 1' ], + [ [ '::', false ], '[::]', 'Bracketed IPv6 0' ], + [ [ '::1', false ], '[::1]', 'Bracketed IPv6 1' ], + [ [ '::1', 80 ], '[::1]:80', 'Bracketed IPv6 with port' ], + [ false, '::x', 'Double colon but no IPv6' ], + [ [ 'x', 80 ], 'x:80', 'Hostname and port' ], + [ false, 'x:x', 'Hostname and invalid port' ], + [ [ 'x', false ], 'x', 'Plain hostname' ] + ]; + } + + /** + * @covers IP::combineHostAndPort() + * @dataProvider provideCombineHostAndPort + */ + public function testCombineHostAndPort( $expected, $input, $description ) { + list( $host, $port, $defaultPort ) = $input; + $this->assertEquals( + $expected, + IP::combineHostAndPort( $host, $port, $defaultPort ), + $description ); + } + + /** + * Provider for IP::combineHostAndPort() + */ + public static function provideCombineHostAndPort() { + return [ + [ '[::1]', [ '::1', 2, 2 ], 'IPv6 default port' ], + [ '[::1]:2', [ '::1', 2, 3 ], 'IPv6 non-default port' ], + [ 'x', [ 'x', 2, 2 ], 'Normal default port' ], + [ 'x:2', [ 'x', 2, 3 ], 'Normal non-default port' ], + ]; + } + + /** + * @covers IP::sanitizeRange() + * @dataProvider provideIPCIDRs + */ + public function testSanitizeRange( $input, $expected, $description ) { + $this->assertEquals( $expected, IP::sanitizeRange( $input ), $description ); + } + + /** + * Provider for IP::testSanitizeRange() + */ + public static function provideIPCIDRs() { + return [ + [ '35.56.31.252/16', '35.56.0.0/16', 'IPv4 range' ], + [ '135.16.21.252/24', '135.16.21.0/24', 'IPv4 range' ], + [ '5.36.71.252/32', '5.36.71.252/32', 'IPv4 silly range' ], + [ '5.36.71.252', '5.36.71.252', 'IPv4 non-range' ], + [ '0:1:2:3:4:c5:f6:7/96', '0:1:2:3:4:C5:0:0/96', 'IPv6 range' ], + [ '0:1:2:3:4:5:6:7/120', '0:1:2:3:4:5:6:0/120', 'IPv6 range' ], + [ '0:e1:2:3:4:5:e6:7/128', '0:E1:2:3:4:5:E6:7/128', 'IPv6 silly range' ], + [ '0:c1:A2:3:4:5:c6:7', '0:C1:A2:3:4:5:C6:7', 'IPv6 non range' ], + ]; + } + + /** + * @covers IP::prettifyIP() + * @dataProvider provideIPsToPrettify + */ + public function testPrettifyIP( $ip, $prettified ) { + $this->assertEquals( $prettified, IP::prettifyIP( $ip ), "Prettify of $ip" ); + } + + /** + * Provider for IP::testPrettifyIP() + */ + public static function provideIPsToPrettify() { + return [ + [ '0:0:0:0:0:0:0:0', '::' ], + [ '0:0:0::0:0:0', '::' ], + [ '0:0:0:1:0:0:0:0', '0:0:0:1::' ], + [ '0:0::f', '::f' ], + [ '0::0:0:0:33:fef:b', '::33:fef:b' ], + [ '3f:535:0:0:0:0:e:fbb', '3f:535::e:fbb' ], + [ '0:0:fef:0:0:0:e:fbb', '0:0:fef::e:fbb' ], + [ 'abbc:2004::0:0:0:0', 'abbc:2004::' ], + [ 'cebc:2004:f:0:0:0:0:0', 'cebc:2004:f::' ], + [ '0:0:0:0:0:0:0:0/16', '::/16' ], + [ '0:0:0::0:0:0/64', '::/64' ], + [ '0:0::f/52', '::f/52' ], + [ '::0:0:33:fef:b/52', '::33:fef:b/52' ], + [ '3f:535:0:0:0:0:e:fbb/48', '3f:535::e:fbb/48' ], + [ '0:0:fef:0:0:0:e:fbb/96', '0:0:fef::e:fbb/96' ], + [ 'abbc:2004:0:0::0:0/40', 'abbc:2004::/40' ], + [ 'aebc:2004:f:0:0:0:0:0/80', 'aebc:2004:f::/80' ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/JavaScriptMinifierTest.php b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php new file mode 100644 index 0000000000..d57d0dd553 --- /dev/null +++ b/tests/phpunit/includes/libs/JavaScriptMinifierTest.php @@ -0,0 +1,367 @@ +setMaxLineLength( 1000 ); + } + + private function setMaxLineLength( $val ) { + $classReflect = new ReflectionClass( JavaScriptMinifier::class ); + $propertyReflect = $classReflect->getProperty( 'maxLineLength' ); + $propertyReflect->setAccessible( true ); + $propertyReflect->setValue( JavaScriptMinifier::class, $val ); + } + + public static function provideCases() { + return [ + + // Basic whitespace and comments that should be stripped entirely + [ "\r\t\f \v\n\r", "" ], + [ "/* Foo *\n*bar\n*/", "" ], + + /** + * Slashes used inside block comments (T28931). + * At some point there was a bug that caused this comment to be ended at '* /', + * causing /M... to be left as the beginning of a regex. + */ + [ + "/**\n * Foo\n * {\n * 'bar' : {\n * " + . "//Multiple rules with configurable operators\n * 'baz' : false\n * }\n */", + "" ], + + /** + * ' Foo \' bar \ + * baz \' quox ' . + */ + [ + "' Foo \\' bar \\\n baz \\' quox ' .length", + "' Foo \\' bar \\\n baz \\' quox '.length" + ], + [ + "\" Foo \\\" bar \\\n baz \\\" quox \" .length", + "\" Foo \\\" bar \\\n baz \\\" quox \".length" + ], + [ "// Foo b/ar baz", "" ], + [ + "/ Foo \\/ bar [ / \\] / ] baz / .length", + "/ Foo \\/ bar [ / \\] / ] baz /.length" + ], + + // HTML comments + [ " 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;cparse( $minified, 'minify-test.js', 1 ); + } + + $this->assertEquals( + $expectedOutput, + $minified, + "Minified output should be in the form expected." + ); + } + + public static function provideLineBreaker() { + return [ + [ + // Regression tests for T34548. + // Must not break between 'E' and '+'. + 'var name = 1.23456789E55;', + [ + 'var', + 'name', + '=', + '1.23456789E55', + ';', + ], + ], + [ + 'var name = 1.23456789E+5;', + [ + 'var', + 'name', + '=', + '1.23456789E+5', + ';', + ], + ], + [ + 'var name = 1.23456789E-5;', + [ + 'var', + 'name', + '=', + '1.23456789E-5', + ';', + ], + ], + [ + // Must not break before '++' + 'if(x++);', + [ + 'if', + '(', + 'x++', + ')', + ';', + ], + ], + [ + // Regression test for T201606. + // Must not break between 'return' and Expression. + // Was caused by bad state after '{}' in property value. + <<setMaxLineLength( 1 ); + $actual = JavaScriptMinifier::minify( $code ); + $this->assertEquals( + array_merge( [ '' ], $expectedLines ), + explode( "\n", $actual ) + ); + } +} diff --git a/tests/phpunit/includes/libs/MapCacheLRUTest.php b/tests/phpunit/includes/libs/MapCacheLRUTest.php new file mode 100644 index 0000000000..7147c6fa22 --- /dev/null +++ b/tests/phpunit/includes/libs/MapCacheLRUTest.php @@ -0,0 +1,267 @@ + 4, 'c' => 3, 'b' => 2, 'a' => 1 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $this->assertEquals( 3, $cache->getMaxSize() ); + $this->assertSame( true, $cache->has( 'a' ) ); + $this->assertSame( true, $cache->has( 'b' ) ); + $this->assertSame( true, $cache->has( 'c' ) ); + $this->assertSame( 1, $cache->get( 'a' ) ); + $this->assertSame( 2, $cache->get( 'b' ) ); + $this->assertSame( 3, $cache->get( 'c' ) ); + + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + $this->assertSame( + [ 'a', 'b', 'c' ], + $cache->getAllKeys() + ); + + $cache->clear( 'a' ); + $this->assertSame( + [ 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $cache->clear(); + $this->assertSame( + [], + $cache->toArray() + ); + + $cache = MapCacheLRU::newFromArray( [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], 4 ); + $cache->setMaxSize( 3 ); + $this->assertSame( + [ 'c' => 3, 'b' => 2, 'a' => 1 ], + $cache->toArray() + ); + } + + /** + * @covers MapCacheLRU::serialize() + * @covers MapCacheLRU::unserialize() + */ + function testSerialize() { + $cache = MapCacheLRU::newFromArray( [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], 10 ); + $string = serialize( $cache ); + $ncache = unserialize( $string ); + $this->assertSame( + [ 'd' => 4, 'c' => 3, 'b' => 2, 'a' => 1 ], + $ncache->toArray() + ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + */ + function testLRU() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $this->assertSame( true, $cache->has( 'c' ) ); + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $this->assertSame( 3, $cache->get( 'c' ) ); + $this->assertSame( + [ 'a' => 1, 'b' => 2, 'c' => 3 ], + $cache->toArray() + ); + + $this->assertSame( 1, $cache->get( 'a' ) ); + $this->assertSame( + [ 'b' => 2, 'c' => 3, 'a' => 1 ], + $cache->toArray() + ); + + $cache->set( 'a', 1 ); + $this->assertSame( + [ 'b' => 2, 'c' => 3, 'a' => 1 ], + $cache->toArray() + ); + + $cache->set( 'b', 22 ); + $this->assertSame( + [ 'c' => 3, 'a' => 1, 'b' => 22 ], + $cache->toArray() + ); + + $cache->set( 'd', 4 ); + $this->assertSame( + [ 'a' => 1, 'b' => 22, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'e', 5, 0.33 ); + $this->assertSame( + [ 'e' => 5, 'b' => 22, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'f', 6, 0.66 ); + $this->assertSame( + [ 'b' => 22, 'f' => 6, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'g', 7, 0.90 ); + $this->assertSame( + [ 'f' => 6, 'g' => 7, 'd' => 4 ], + $cache->toArray() + ); + + $cache->set( 'g', 7, 1.0 ); + $this->assertSame( + [ 'f' => 6, 'd' => 4, 'g' => 7 ], + $cache->toArray() + ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + */ + public function testExpiry() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->set( 'd', 'xxx' ); + $this->assertTrue( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + + $now += 29; + $this->assertTrue( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + $this->assertEquals( 'xxx', $cache->get( 'd', 30 ) ); + + $now += 1.5; + $this->assertFalse( $cache->has( 'd', 30 ) ); + $this->assertEquals( 'xxx', $cache->get( 'd' ) ); + $this->assertNull( $cache->get( 'd', 30 ) ); + } + + /** + * @covers MapCacheLRU::hasField() + * @covers MapCacheLRU::getField() + * @covers MapCacheLRU::setField() + */ + public function testFields() { + $raw = [ 'a' => 1, 'b' => 2, 'c' => 3 ]; + $cache = MapCacheLRU::newFromArray( $raw, 3 ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->setField( 'PMs', 'Tony Blair', 'Labour' ); + $cache->setField( 'PMs', 'Margaret Thatcher', 'Tory' ); + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + + $now += 29; + $this->assertTrue( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair', 30 ) ); + + $now += 1.5; + $this->assertFalse( $cache->hasField( 'PMs', 'Tony Blair', 30 ) ); + $this->assertEquals( 'Labour', $cache->getField( 'PMs', 'Tony Blair' ) ); + $this->assertNull( $cache->getField( 'PMs', 'Tony Blair', 30 ) ); + + $this->assertEquals( + [ 'Tony Blair' => 'Labour', 'Margaret Thatcher' => 'Tory' ], + $cache->get( 'PMs' ) + ); + + $cache->set( 'MPs', [ + 'Edwina Currie' => 1983, + 'Neil Kinnock' => 1970 + ] ); + $this->assertEquals( + [ + 'Edwina Currie' => 1983, + 'Neil Kinnock' => 1970 + ], + $cache->get( 'MPs' ) + ); + + $this->assertEquals( 1983, $cache->getField( 'MPs', 'Edwina Currie' ) ); + $this->assertEquals( 1970, $cache->getField( 'MPs', 'Neil Kinnock' ) ); + } + + /** + * @covers MapCacheLRU::has() + * @covers MapCacheLRU::get() + * @covers MapCacheLRU::set() + * @covers MapCacheLRU::hasField() + * @covers MapCacheLRU::getField() + * @covers MapCacheLRU::setField() + */ + public function testInvalidKeys() { + $cache = MapCacheLRU::newFromArray( [], 3 ); + + try { + $cache->has( 3.4 ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->get( false ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->set( 3.4, 'x' ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + + try { + $cache->hasField( 'x', 3.4 ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->getField( 'x', false ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + try { + $cache->setField( 'x', 3.4, 'x' ); + $this->fail( "No exception" ); + } catch ( UnexpectedValueException $e ) { + $this->assertRegExp( '/must be string or integer/', $e->getMessage() ); + } + } +} diff --git a/tests/phpunit/includes/libs/MemoizedCallableTest.php b/tests/phpunit/includes/libs/MemoizedCallableTest.php new file mode 100644 index 0000000000..628cca0c68 --- /dev/null +++ b/tests/phpunit/includes/libs/MemoizedCallableTest.php @@ -0,0 +1,142 @@ +getMockBuilder( stdClass::class ) + ->setMethods( [ 'reverse' ] )->getMock(); + $mock->expects( $this->any() ) + ->method( 'reverse' ) + ->will( $this->returnCallback( 'strrev' ) ); + + $memoized = new MemoizedCallable( [ $mock, 'reverse' ] ); + $this->assertEquals( 'flow', $memoized->invoke( 'wolf' ) ); + } + + /** + * Consecutive calls to the memoized callable with the same arguments + * should result in just one invocation of the underlying callable. + * + * @requires extension apcu + */ + public function testCallableMemoized() { + $observer = $this->getMockBuilder( stdClass::class ) + ->setMethods( [ 'computeSomething' ] )->getMock(); + $observer->expects( $this->once() ) + ->method( 'computeSomething' ) + ->will( $this->returnValue( 'ok' ) ); + + $memoized = new ArrayBackedMemoizedCallable( [ $observer, 'computeSomething' ] ); + + // First invocation -- delegates to $observer->computeSomething() + $this->assertEquals( 'ok', $memoized->invoke() ); + + // Second invocation -- returns memoized result + $this->assertEquals( 'ok', $memoized->invoke() ); + } + + /** + * @covers MemoizedCallable::invoke + */ + public function testInvokeVariadic() { + $memoized = new MemoizedCallable( 'sprintf' ); + $this->assertEquals( + $memoized->invokeArgs( [ 'this is %s', 'correct' ] ), + $memoized->invoke( 'this is %s', 'correct' ) + ); + } + + /** + * @covers MemoizedCallable::call + */ + public function testShortcutMethod() { + $this->assertEquals( + 'this is correct', + MemoizedCallable::call( 'sprintf', [ 'this is %s', 'correct' ] ) + ); + } + + /** + * Outlier TTL values should be coerced to range 1 - 86400. + */ + public function testTTLMaxMin() { + $memoized = new MemoizedCallable( 'abs', 100000 ); + $this->assertEquals( 86400, $this->readAttribute( $memoized, 'ttl' ) ); + + $memoized = new MemoizedCallable( 'abs', -10 ); + $this->assertEquals( 1, $this->readAttribute( $memoized, 'ttl' ) ); + } + + /** + * Closure names should be distinct. + */ + public function testMemoizedClosure() { + $a = new MemoizedCallable( function () { + return 'a'; + } ); + + $b = new MemoizedCallable( function () { + return 'b'; + } ); + + $this->assertEquals( $a->invokeArgs(), 'a' ); + $this->assertEquals( $b->invokeArgs(), 'b' ); + + $this->assertNotEquals( + $this->readAttribute( $a, 'callableName' ), + $this->readAttribute( $b, 'callableName' ) + ); + + $c = new ArrayBackedMemoizedCallable( function () { + return rand(); + } ); + $this->assertEquals( $c->invokeArgs(), $c->invokeArgs(), 'memoized random' ); + } + + /** + * @expectedExceptionMessage non-scalar argument + * @expectedException InvalidArgumentException + */ + public function testNonScalarArguments() { + $memoized = new MemoizedCallable( 'gettype' ); + $memoized->invoke( new stdClass() ); + } + + /** + * @expectedExceptionMessage must be an instance of callable + * @expectedException InvalidArgumentException + */ + public function testNotCallable() { + $memoized = new MemoizedCallable( 14 ); + } +} + +/** + * A MemoizedCallable subclass that stores function return values + * in an instance property rather than APC or APCu. + */ +class ArrayBackedMemoizedCallable extends MemoizedCallable { + private $cache = []; + + protected function fetchResult( $key, &$success ) { + if ( array_key_exists( $key, $this->cache ) ) { + $success = true; + return $this->cache[$key]; + } + $success = false; + return false; + } + + protected function storeResult( $key, $result ) { + $this->cache[$key] = $result; + } +} diff --git a/tests/phpunit/includes/libs/ProcessCacheLRUTest.php b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php new file mode 100644 index 0000000000..8e91e70cb0 --- /dev/null +++ b/tests/phpunit/includes/libs/ProcessCacheLRUTest.php @@ -0,0 +1,264 @@ +assertEquals( 0, $cache->getEntriesCount(), $msg ); + } + + /** + * Helper to fill a cache object passed by reference + */ + protected function fillCache( &$cache, $numEntries ) { + // Fill cache with three values + for ( $i = 1; $i <= $numEntries; $i++ ) { + $cache->set( "cache-key-$i", "prop-$i", "value-$i" ); + } + } + + /** + * Generates an array of what would be expected in cache for a given cache + * size and a number of entries filled in sequentially + */ + protected function getExpectedCache( $cacheMaxEntries, $entryToFill ) { + $expected = []; + + if ( $entryToFill === 0 ) { + // The cache is empty! + return []; + } elseif ( $entryToFill <= $cacheMaxEntries ) { + // Cache is not fully filled + $firstKey = 1; + } else { + // Cache overflowed + $firstKey = 1 + $entryToFill - $cacheMaxEntries; + } + + $lastKey = $entryToFill; + + for ( $i = $firstKey; $i <= $lastKey; $i++ ) { + $expected["cache-key-$i"] = [ "prop-$i" => "value-$i" ]; + } + + return $expected; + } + + /** + * Highlight diff between assertEquals and assertNotSame + * @coversNothing + */ + public function testPhpUnitArrayEquality() { + $one = [ 'A' => 1, 'B' => 2 ]; + $two = [ 'B' => 2, 'A' => 1 ]; + // == + $this->assertEquals( $one, $two ); + // === + $this->assertNotSame( $one, $two ); + } + + /** + * @dataProvider provideInvalidConstructorArg + * @expectedException Wikimedia\Assert\ParameterAssertionException + * @covers ProcessCacheLRU::__construct + */ + public function testConstructorGivenInvalidValue( $maxSize ) { + new ProcessCacheLRUTestable( $maxSize ); + } + + /** + * Value which are forbidden by the constructor + */ + public static function provideInvalidConstructorArg() { + return [ + [ null ], + [ [] ], + [ new stdClass() ], + [ 0 ], + [ '5' ], + [ -1 ], + ]; + } + + /** + * @covers ProcessCacheLRU::get + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::has + */ + public function testAddAndGetAKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + // First set just one value + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $this->assertEquals( 1, $oneCache->getEntriesCount() ); + $this->assertTrue( $oneCache->has( 'cache-key', 'prop1' ) ); + $this->assertEquals( 'value1', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + /** + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::get + */ + public function testDeleteOldKey() { + $oneCache = new ProcessCacheLRUTestable( 1 ); + $this->assertCacheEmpty( $oneCache ); + + $oneCache->set( 'cache-key', 'prop1', 'value1' ); + $oneCache->set( 'cache-key', 'prop1', 'value2' ); + $this->assertEquals( 'value2', $oneCache->get( 'cache-key', 'prop1' ) ); + } + + /** + * This test that we properly overflow when filling a cache with + * a sequence of always different cache-keys. Meant to verify we correclty + * delete the older key. + * + * @covers ProcessCacheLRU::set + * @dataProvider provideCacheFilling + * @param int $cacheMaxEntries Maximum entry the created cache will hold + * @param int $entryToFill Number of entries to insert in the created cache. + */ + public function testFillingCache( $cacheMaxEntries, $entryToFill, $msg = '' ) { + $cache = new ProcessCacheLRUTestable( $cacheMaxEntries ); + $this->fillCache( $cache, $entryToFill ); + + $this->assertSame( + $this->getExpectedCache( $cacheMaxEntries, $entryToFill ), + $cache->getCache(), + "Filling a $cacheMaxEntries entries cache with $entryToFill entries" + ); + } + + /** + * Provider for testFillingCache + */ + public static function provideCacheFilling() { + // ($cacheMaxEntries, $entryToFill, $msg='') + return [ + [ 1, 0 ], + [ 1, 1 ], + // overflow + [ 1, 2 ], + // overflow + [ 5, 33 ], + ]; + } + + /** + * Create a cache with only one remaining entry then update + * the first inserted entry. Should bump it to the top. + * + * @covers ProcessCacheLRU::set + */ + public function testReplaceExistingKeyShouldBumpEntryToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + // Fill cache leaving just one remaining slot + $this->fillCache( $cache, $maxEntries - 1 ); + + // Set an existing cache key + $cache->set( "cache-key-1", "prop-1", "new-value-for-1" ); + + $this->assertSame( + [ + 'cache-key-2' => [ 'prop-2' => 'value-2' ], + 'cache-key-1' => [ 'prop-1' => 'new-value-for-1' ], + ], + $cache->getCache() + ); + } + + /** + * @covers ProcessCacheLRU::get + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::has + */ + public function testRecentlyAccessedKeyStickIn() { + $cache = new ProcessCacheLRUTestable( 2 ); + $cache->set( 'first', 'prop1', 'value1' ); + $cache->set( 'second', 'prop2', 'value2' ); + + // Get first + $cache->get( 'first', 'prop1' ); + // Cache a third value, should invalidate the least used one + $cache->set( 'third', 'prop3', 'value3' ); + + $this->assertFalse( $cache->has( 'second', 'prop2' ) ); + } + + /** + * This first create a full cache then update the value for the 2nd + * filled entry. + * Given a cache having 1,2,3 as key, updating 2 should bump 2 to + * the top of the queue with the new value: 1,3,2* (* = updated). + * + * @covers ProcessCacheLRU::set + * @covers ProcessCacheLRU::get + */ + public function testReplaceExistingKeyInAFullCacheShouldBumpToTop() { + $maxEntries = 3; + + $cache = new ProcessCacheLRUTestable( $maxEntries ); + $this->fillCache( $cache, $maxEntries ); + + // Set an existing cache key + $cache->set( "cache-key-2", "prop-2", "new-value-for-2" ); + $this->assertSame( + [ + 'cache-key-1' => [ 'prop-1' => 'value-1' ], + 'cache-key-3' => [ 'prop-3' => 'value-3' ], + 'cache-key-2' => [ 'prop-2' => 'new-value-for-2' ], + ], + $cache->getCache() + ); + $this->assertEquals( 'new-value-for-2', + $cache->get( 'cache-key-2', 'prop-2' ) + ); + } + + /** + * @covers ProcessCacheLRU::set + */ + public function testBumpExistingKeyToTop() { + $cache = new ProcessCacheLRUTestable( 3 ); + $this->fillCache( $cache, 3 ); + + // Set the very first cache key to a new value + $cache->set( "cache-key-1", "prop-1", "new value for 1" ); + $this->assertEquals( + [ + 'cache-key-2' => [ 'prop-2' => 'value-2' ], + 'cache-key-3' => [ 'prop-3' => 'value-3' ], + 'cache-key-1' => [ 'prop-1' => 'new value for 1' ], + ], + $cache->getCache() + ); + } +} + +/** + * Overrides some ProcessCacheLRU methods and properties accessibility. + */ +class ProcessCacheLRUTestable extends ProcessCacheLRU { + public function getCache() { + return $this->cache->toArray(); + } + + public function getEntriesCount() { + return count( $this->cache->toArray() ); + } +} diff --git a/tests/phpunit/includes/libs/SamplingStatsdClientTest.php b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php new file mode 100644 index 0000000000..7bd161156d --- /dev/null +++ b/tests/phpunit/includes/libs/SamplingStatsdClientTest.php @@ -0,0 +1,77 @@ +getMockBuilder( SenderInterface::class )->getMock(); + $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) ); + if ( $expectWrite ) { + $sender->expects( $this->once() )->method( 'write' ) + ->with( $this->anything(), $this->equalTo( $data ) ); + } else { + $sender->expects( $this->never() )->method( 'write' ); + } + if ( defined( 'MT_RAND_PHP' ) ) { + mt_srand( $seed, MT_RAND_PHP ); + } else { + mt_srand( $seed ); + } + $client = new SamplingStatsdClient( $sender ); + $client->send( $data, $sampleRate ); + } + + public function samplingDataProvider() { + $unsampled = new StatsdData(); + $unsampled->setKey( 'foo' ); + $unsampled->setValue( 1 ); + + $sampled = new StatsdData(); + $sampled->setKey( 'foo' ); + $sampled->setValue( 1 ); + $sampled->setSampleRate( '0.1' ); + + return [ + // $data, $sampleRate, $seed, $expectWrite + [ $unsampled, 1, 0 /*0.44*/, true ], + [ $sampled, 1, 0 /*0.44*/, false ], + [ $sampled, 1, 4 /*0.03*/, true ], + [ $unsampled, 0.1, 0 /*0.44*/, false ], + [ $sampled, 0.5, 0 /*0.44*/, false ], + [ $sampled, 0.5, 4 /*0.03*/, false ], + ]; + } + + public function testSetSamplingRates() { + $matching = new StatsdData(); + $matching->setKey( 'foo.bar' ); + $matching->setValue( 1 ); + + $nonMatching = new StatsdData(); + $nonMatching->setKey( 'oof.bar' ); + $nonMatching->setValue( 1 ); + + $sender = $this->getMockBuilder( SenderInterface::class )->getMock(); + $sender->expects( $this->any() )->method( 'open' )->will( $this->returnValue( true ) ); + $sender->expects( $this->once() )->method( 'write' )->with( $this->anything(), + $this->equalTo( $nonMatching ) ); + + $client = new SamplingStatsdClient( $sender ); + $client->setSamplingRates( [ 'foo.*' => 0.2 ] ); + + mt_srand( 0 ); // next random is 0.44 + $client->send( $matching ); + mt_srand( 0 ); + $client->send( $nonMatching ); + } +} diff --git a/tests/phpunit/includes/libs/StaticArrayWriterTest.php b/tests/phpunit/includes/libs/StaticArrayWriterTest.php new file mode 100644 index 0000000000..4bd845d0b9 --- /dev/null +++ b/tests/phpunit/includes/libs/StaticArrayWriterTest.php @@ -0,0 +1,58 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + * + */ + +use Wikimedia\StaticArrayWriter; + +/** + * @covers \Wikimedia\StaticArrayWriter + */ +class StaticArrayWriterTest extends PHPUnit\Framework\TestCase { + public function testCreate() { + $data = [ + 'foo' => 'bar', + 'baz' => 'rawr', + "they're" => '"quoted properly"', + 'nested' => [ 'elements', 'work' ], + 'and' => [ 'these' => 'do too' ], + ]; + $writer = new StaticArrayWriter(); + $actual = $writer->create( $data, "Header\nWith\nNewlines" ); + $expected = << 'bar', + 'baz' => 'rawr', + 'they\'re' => '"quoted properly"', + 'nested' => [ + 0 => 'elements', + 1 => 'work', + ], + 'and' => [ + 'these' => 'do too', + ], +]; + +PHP; + $this->assertSame( $expected, $actual ); + } +} diff --git a/tests/phpunit/includes/libs/StringUtilsTest.php b/tests/phpunit/includes/libs/StringUtilsTest.php new file mode 100644 index 0000000000..fcfa53e22d --- /dev/null +++ b/tests/phpunit/includes/libs/StringUtilsTest.php @@ -0,0 +1,128 @@ +assertEquals( $expected, StringUtils::isUtf8( $string ), + 'Testing string "' . $this->escaped( $string ) . '"' ); + } + + /** + * Print high range characters as a hexadecimal + * @param string $string + * @return string + */ + function escaped( $string ) { + $escaped = ''; + $length = strlen( $string ); + for ( $i = 0; $i < $length; $i++ ) { + $char = $string[$i]; + $val = ord( $char ); + if ( $val > 127 ) { + $escaped .= '\x' . dechex( $val ); + } else { + $escaped .= $char; + } + } + + return $escaped; + } + + /** + * See also "UTF-8 decoder capability and stress test" by + * Markus Kuhn: + * http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + */ + public static function provideStringsForIsUtf8Check() { + // Expected return values for StringUtils::isUtf8() + $PASS = true; + $FAIL = false; + + return [ + 'some ASCII' => [ $PASS, 'Some ASCII' ], + 'euro sign' => [ $PASS, "Euro sign €" ], + + 'first possible sequence 1 byte' => [ $PASS, "\x00" ], + 'first possible sequence 2 bytes' => [ $PASS, "\xc2\x80" ], + 'first possible sequence 3 bytes' => [ $PASS, "\xe0\xa0\x80" ], + 'first possible sequence 4 bytes' => [ $PASS, "\xf0\x90\x80\x80" ], + 'first possible sequence 5 bytes' => [ $FAIL, "\xf8\x88\x80\x80\x80" ], + 'first possible sequence 6 bytes' => [ $FAIL, "\xfc\x84\x80\x80\x80\x80" ], + + 'last possible sequence 1 byte' => [ $PASS, "\x7f" ], + 'last possible sequence 2 bytes' => [ $PASS, "\xdf\xbf" ], + 'last possible sequence 3 bytes' => [ $PASS, "\xef\xbf\xbf" ], + 'last possible sequence 4 bytes (U+1FFFFF)' => [ $FAIL, "\xf7\xbf\xbf\xbf" ], + 'last possible sequence 5 bytes' => [ $FAIL, "\xfb\xbf\xbf\xbf\xbf" ], + 'last possible sequence 6 bytes' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf\xbf" ], + + 'boundary 1' => [ $PASS, "\xed\x9f\xbf" ], + 'boundary 2' => [ $PASS, "\xee\x80\x80" ], + 'boundary 3' => [ $PASS, "\xef\xbf\xbd" ], + 'boundary 4' => [ $PASS, "\xf2\x80\x80\x80" ], + 'boundary 5 (U+FFFFF)' => [ $PASS, "\xf3\xbf\xbf\xbf" ], + 'boundary 6 (U+100000)' => [ $PASS, "\xf4\x80\x80\x80" ], + 'boundary 7 (U+10FFFF)' => [ $PASS, "\xf4\x8f\xbf\xbf" ], + 'boundary 8 (U+110000)' => [ $FAIL, "\xf4\x90\x80\x80" ], + + 'malformed 1' => [ $FAIL, "\x80" ], + 'malformed 2' => [ $FAIL, "\xbf" ], + 'malformed 3' => [ $FAIL, "\x80\xbf" ], + 'malformed 4' => [ $FAIL, "\x80\xbf\x80" ], + 'malformed 5' => [ $FAIL, "\x80\xbf\x80\xbf" ], + 'malformed 6' => [ $FAIL, "\x80\xbf\x80\xbf\x80" ], + 'malformed 7' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf" ], + 'malformed 8' => [ $FAIL, "\x80\xbf\x80\xbf\x80\xbf\x80" ], + + 'last byte missing 1' => [ $FAIL, "\xc0" ], + 'last byte missing 2' => [ $FAIL, "\xe0\x80" ], + 'last byte missing 3' => [ $FAIL, "\xf0\x80\x80" ], + 'last byte missing 4' => [ $FAIL, "\xf8\x80\x80\x80" ], + 'last byte missing 5' => [ $FAIL, "\xfc\x80\x80\x80\x80" ], + 'last byte missing 6' => [ $FAIL, "\xdf" ], + 'last byte missing 7' => [ $FAIL, "\xef\xbf" ], + 'last byte missing 8' => [ $FAIL, "\xf7\xbf\xbf" ], + 'last byte missing 9' => [ $FAIL, "\xfb\xbf\xbf\xbf" ], + 'last byte missing 10' => [ $FAIL, "\xfd\xbf\xbf\xbf\xbf" ], + + 'extra continuation byte 1' => [ $FAIL, "e\xaf" ], + 'extra continuation byte 2' => [ $FAIL, "\xc3\x89\xaf" ], + 'extra continuation byte 3' => [ $FAIL, "\xef\xbc\xa5\xaf" ], + 'extra continuation byte 4' => [ $FAIL, "\xf0\x9d\x99\xb4\xaf" ], + + 'impossible bytes 1' => [ $FAIL, "\xfe" ], + 'impossible bytes 2' => [ $FAIL, "\xff" ], + 'impossible bytes 3' => [ $FAIL, "\xfe\xfe\xff\xff" ], + + 'overlong sequences 1' => [ $FAIL, "\xc0\xaf" ], + 'overlong sequences 2' => [ $FAIL, "\xc1\xaf" ], + 'overlong sequences 3' => [ $FAIL, "\xe0\x80\xaf" ], + 'overlong sequences 4' => [ $FAIL, "\xf0\x80\x80\xaf" ], + 'overlong sequences 5' => [ $FAIL, "\xf8\x80\x80\x80\xaf" ], + 'overlong sequences 6' => [ $FAIL, "\xfc\x80\x80\x80\x80\xaf" ], + + 'maximum overlong sequences 1' => [ $FAIL, "\xc1\xbf" ], + 'maximum overlong sequences 2' => [ $FAIL, "\xe0\x9f\xbf" ], + 'maximum overlong sequences 3' => [ $FAIL, "\xf0\x8f\xbf\xbf" ], + 'maximum overlong sequences 4' => [ $FAIL, "\xf8\x87\xbf\xbf" ], + 'maximum overlong sequences 5' => [ $FAIL, "\xfc\x83\xbf\xbf\xbf\xbf" ], + + 'surrogates 1 (U+D799)' => [ $PASS, "\xed\x9f\xbf" ], + 'surrogates 2 (U+E000)' => [ $PASS, "\xee\x80\x80" ], + 'surrogates 3 (U+D800)' => [ $FAIL, "\xed\xa0\x80" ], + 'surrogates 4 (U+DBFF)' => [ $FAIL, "\xed\xaf\xbf" ], + 'surrogates 5 (U+DC00)' => [ $FAIL, "\xed\xb0\x80" ], + 'surrogates 6 (U+DFFF)' => [ $FAIL, "\xed\xbf\xbf" ], + 'surrogates 7 (U+D800 U+DC00)' => [ $FAIL, "\xed\xa0\x80\xed\xb0\x80" ], + + 'noncharacters 1' => [ $PASS, "\xef\xbf\xbe" ], + 'noncharacters 2' => [ $PASS, "\xef\xbf\xbf" ], + ]; + } +} diff --git a/tests/phpunit/includes/libs/TimingTest.php b/tests/phpunit/includes/libs/TimingTest.php new file mode 100644 index 0000000000..581a518626 --- /dev/null +++ b/tests/phpunit/includes/libs/TimingTest.php @@ -0,0 +1,115 @@ + + */ + +class TimingTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + /** + * @covers Timing::clearMarks + * @covers Timing::getEntries + */ + public function testClearMarks() { + $timing = new Timing; + $this->assertCount( 1, $timing->getEntries() ); + + $timing->mark( 'a' ); + $timing->mark( 'b' ); + $this->assertCount( 3, $timing->getEntries() ); + + $timing->clearMarks( 'a' ); + $this->assertNull( $timing->getEntryByName( 'a' ) ); + $this->assertNotNull( $timing->getEntryByName( 'b' ) ); + + $timing->clearMarks(); + $this->assertCount( 1, $timing->getEntries() ); + } + + /** + * @covers Timing::mark + * @covers Timing::getEntryByName + */ + public function testMark() { + $timing = new Timing; + $timing->mark( 'a' ); + + $entry = $timing->getEntryByName( 'a' ); + $this->assertEquals( 'a', $entry['name'] ); + $this->assertEquals( 'mark', $entry['entryType'] ); + $this->assertArrayHasKey( 'startTime', $entry ); + $this->assertEquals( 0, $entry['duration'] ); + + usleep( 100 ); + $timing->mark( 'a' ); + $newEntry = $timing->getEntryByName( 'a' ); + $this->assertGreaterThan( $entry['startTime'], $newEntry['startTime'] ); + } + + /** + * @covers Timing::measure + */ + public function testMeasure() { + $timing = new Timing; + + $timing->mark( 'a' ); + usleep( 100 ); + $timing->mark( 'b' ); + + $a = $timing->getEntryByName( 'a' ); + $b = $timing->getEntryByName( 'b' ); + + $timing->measure( 'a_to_b', 'a', 'b' ); + + $entry = $timing->getEntryByName( 'a_to_b' ); + $this->assertEquals( 'a_to_b', $entry['name'] ); + $this->assertEquals( 'measure', $entry['entryType'] ); + $this->assertEquals( $a['startTime'], $entry['startTime'] ); + $this->assertEquals( $b['startTime'] - $a['startTime'], $entry['duration'] ); + } + + /** + * @covers Timing::getEntriesByType + */ + public function testGetEntriesByType() { + $timing = new Timing; + + $timing->mark( 'mark_a' ); + usleep( 100 ); + $timing->mark( 'mark_b' ); + usleep( 100 ); + $timing->mark( 'mark_c' ); + + $timing->measure( 'measure_a', 'mark_a', 'mark_b' ); + $timing->measure( 'measure_b', 'mark_b', 'mark_c' ); + + $marks = array_map( function ( $entry ) { + return $entry['name']; + }, $timing->getEntriesByType( 'mark' ) ); + + $this->assertEquals( [ 'requestStart', 'mark_a', 'mark_b', 'mark_c' ], $marks ); + + $measures = array_map( function ( $entry ) { + return $entry['name']; + }, $timing->getEntriesByType( 'measure' ) ); + + $this->assertEquals( [ 'measure_a', 'measure_b' ], $measures ); + } +} diff --git a/tests/phpunit/includes/libs/XhprofDataTest.php b/tests/phpunit/includes/libs/XhprofDataTest.php new file mode 100644 index 0000000000..3e9379456d --- /dev/null +++ b/tests/phpunit/includes/libs/XhprofDataTest.php @@ -0,0 +1,274 @@ +assertSame( $expect, XhprofData::splitKey( $key ) ); + } + + public function provideSplitKey() { + return [ + [ 'main()', [ null, 'main()' ] ], + [ 'foo==>bar', [ 'foo', 'bar' ] ], + [ 'bar@1==>bar@2', [ 'bar@1', 'bar@2' ] ], + [ 'foo==>bar==>baz', [ 'foo', 'bar==>baz' ] ], + [ '==>bar', [ '', 'bar' ] ], + [ '', [ null, '' ] ], + ]; + } + + /** + * @covers XhprofData::pruneData + */ + public function testInclude() { + $xhprofData = $this->getXhprofDataFixture( [ + 'include' => [ 'main()' ], + ] ); + $raw = $xhprofData->getRawData(); + $this->assertArrayHasKey( 'main()', $raw ); + $this->assertArrayHasKey( 'main()==>foo', $raw ); + $this->assertArrayHasKey( 'main()==>xhprof_disable', $raw ); + $this->assertSame( 3, count( $raw ) ); + } + + /** + * Validate the structure of data returned by + * Xhprof::getInclusiveMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getInclusiveMetrics + */ + public function testInclusiveMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getInclusiveMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric ); + + foreach ( $metricStruct as $key => $type ) { + if ( $type === 'array' ) { + $this->assertArrayStructure( $statStruct, $metric[$key] ); + if ( $name === 'main()' ) { + $this->assertEquals( 100, $metric[$key]['percent'] ); + } + } + } + } + } + + /** + * Validate the structure of data returned by + * Xhprof::getCompleteMetrics(). This acts as a guard against unexpected + * structural changes to the returned data in lieu of using a more heavy + * weight typed response object. + * + * @covers XhprofData::getCompleteMetrics + */ + public function testCompleteMetricsStructure() { + $metricStruct = [ + 'ct' => 'int', + 'wt' => 'array', + 'cpu' => 'array', + 'mu' => 'array', + 'pmu' => 'array', + 'calls' => 'array', + 'subcalls' => 'array', + ]; + $statsMetrics = [ 'wt', 'cpu', 'mu', 'pmu' ]; + $statStruct = [ + 'total' => 'numeric', + 'min' => 'numeric', + 'mean' => 'numeric', + 'max' => 'numeric', + 'variance' => 'numeric', + 'percent' => 'numeric', + 'exclusive' => 'numeric', + ]; + + $xhprofData = $this->getXhprofDataFixture(); + $metrics = $xhprofData->getCompleteMetrics(); + + foreach ( $metrics as $name => $metric ) { + $this->assertArrayStructure( $metricStruct, $metric, $name ); + + foreach ( $metricStruct as $key => $type ) { + if ( in_array( $key, $statsMetrics ) ) { + $this->assertArrayStructure( + $statStruct, $metric[$key], $key + ); + $this->assertLessThanOrEqual( + $metric[$key]['total'], $metric[$key]['exclusive'] + ); + } + } + } + } + + /** + * @covers XhprofData::getCallers + * @covers XhprofData::getCallees + */ + public function testEdges() { + $xhprofData = $this->getXhprofDataFixture(); + $this->assertSame( [], $xhprofData->getCallers( 'main()' ) ); + $this->assertSame( [ 'foo', 'xhprof_disable' ], + $xhprofData->getCallees( 'main()' ) + ); + $this->assertSame( [ 'main()' ], + $xhprofData->getCallers( 'foo' ) + ); + $this->assertSame( [], $xhprofData->getCallees( 'strlen' ) ); + } + + /** + * @covers XhprofData::getCriticalPath + */ + public function testCriticalPath() { + $xhprofData = $this->getXhprofDataFixture(); + $path = $xhprofData->getCriticalPath(); + + $last = null; + foreach ( $path as $key => $value ) { + list( $func, $call ) = XhprofData::splitKey( $key ); + $this->assertSame( $last, $func ); + $last = $call; + } + $this->assertSame( $last, 'bar@1' ); + } + + /** + * Get an Xhprof instance that has been primed with a set of known testing + * data. Tests for the Xhprof class should laregly be concerned with + * evaluating the manipulations of the data collected by xhprof rather + * than the data collection process itself. + * + * The returned Xhprof instance primed will be with a data set created by + * running this trivial program using the PECL xhprof implementation: + * @code + * function bar( $x ) { + * if ( $x > 0 ) { + * bar($x - 1); + * } + * } + * function foo() { + * for ( $idx = 0; $idx < 2; $idx++ ) { + * bar( $idx ); + * $x = strlen( 'abc' ); + * } + * } + * xhprof_enable( XHPROF_FLAGS_CPU | XHPROF_FLAGS_MEMORY ); + * foo(); + * $x = xhprof_disable(); + * var_export( $x ); + * @endcode + * + * @return Xhprof + */ + protected function getXhprofDataFixture( array $opts = [] ) { + return new XhprofData( [ + 'foo==>bar' => [ + 'ct' => 2, + 'wt' => 57, + 'cpu' => 92, + 'mu' => 1896, + 'pmu' => 0, + ], + 'foo==>strlen' => [ + 'ct' => 2, + 'wt' => 21, + 'cpu' => 141, + 'mu' => 752, + 'pmu' => 0, + ], + 'bar==>bar@1' => [ + 'ct' => 1, + 'wt' => 18, + 'cpu' => 19, + 'mu' => 752, + 'pmu' => 0, + ], + 'main()==>foo' => [ + 'ct' => 1, + 'wt' => 304, + 'cpu' => 307, + 'mu' => 4008, + 'pmu' => 0, + ], + 'main()==>xhprof_disable' => [ + 'ct' => 1, + 'wt' => 8, + 'cpu' => 10, + 'mu' => 768, + 'pmu' => 392, + ], + 'main()' => [ + 'ct' => 1, + 'wt' => 353, + 'cpu' => 351, + 'mu' => 6112, + 'pmu' => 1424, + ], + ], $opts ); + } + + /** + * Assert that the given array has the described structure. + * + * @param array $struct Array of key => type mappings + * @param array $actual Array to check + * @param string $label + */ + protected function assertArrayStructure( $struct, $actual, $label = null ) { + $this->assertInternalType( 'array', $actual, $label ); + $this->assertCount( count( $struct ), $actual, $label ); + foreach ( $struct as $key => $type ) { + $this->assertArrayHasKey( $key, $actual ); + $this->assertInternalType( $type, $actual[$key] ); + } + } +} diff --git a/tests/phpunit/includes/libs/XhprofTest.php b/tests/phpunit/includes/libs/XhprofTest.php new file mode 100644 index 0000000000..ccad4a43d6 --- /dev/null +++ b/tests/phpunit/includes/libs/XhprofTest.php @@ -0,0 +1,113 @@ +getProperty( 'enabled' ); + $enabled->setAccessible( true ); + $enabled->setValue( true ); + $xhprof->getMethod( 'enable' )->invoke( null ); + } + + /** + * callAny() calls the first function of the list. + * + * @covers Xhprof::callAny + * @dataProvider provideCallAny + */ + public function testCallAny( array $functions, array $args, $expectedResult ) { + $xhprof = new ReflectionClass( Xhprof::class ); + $callAny = $xhprof->getMethod( 'callAny' ); + $callAny->setAccessible( true ); + + $this->assertEquals( $expectedResult, + $callAny->invoke( null, $functions, $args ) ); + } + + /** + * Data provider for testCallAny(). + */ + public function provideCallAny() { + return [ + [ + [ 'wfTestCallAny_func1', 'wfTestCallAny_func2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + 12 + ], + [ + [ 'wfTestCallAny_nosuchfunc1', 'wfTestCallAny_func2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + 7 + ], + [ + [ 'wfTestCallAny_nosuchfunc1', 'wfTestCallAny_nosuchfunc2', 'wfTestCallAny_func3' ], + [ 3, 4 ], + -1 + ] + + ]; + } + + /** + * callAny() throws an exception when all functions are unavailable. + * + * @expectedException Exception + * @expectedExceptionMessage Neither xhprof nor tideways are installed + * @covers Xhprof::callAny + */ + public function testCallAnyNoneAvailable() { + $xhprof = new ReflectionClass( Xhprof::class ); + $callAny = $xhprof->getMethod( 'callAny' ); + $callAny->setAccessible( true ); + + $callAny->invoke( $xhprof, [ + 'wfTestCallAny_nosuchfunc1', + 'wfTestCallAny_nosuchfunc2', + 'wfTestCallAny_nosuchfunc3' + ] ); + } +} + +/** Test function #1 for XhprofTest::testCallAny */ +function wfTestCallAny_func1( $a, $b ) { + return $a * $b; +} + +/** Test function #2 for XhprofTest::testCallAny */ +function wfTestCallAny_func2( $a, $b ) { + return $a + $b; +} + +/** Test function #3 for XhprofTest::testCallAny */ +function wfTestCallAny_func3( $a, $b ) { + return $a - $b; +} diff --git a/tests/phpunit/includes/libs/XmlTypeCheckTest.php b/tests/phpunit/includes/libs/XmlTypeCheckTest.php new file mode 100644 index 0000000000..8616b41922 --- /dev/null +++ b/tests/phpunit/includes/libs/XmlTypeCheckTest.php @@ -0,0 +1,79 @@ +"; + const MAL_FORMED_XML = ""; + // phpcs:ignore Generic.Files.LineLength + const XML_WITH_PIH = ''; + + /** + * @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' + + + + + + + + + +]> + +&test; + +XML; + $check = XmlTypeCheck::newFromString( $xml ); + $this->assertFalse( $check->wellFormed ); + } + + /** + * @covers XMLTypeCheck::processingInstructionHandler + */ + public function testProcessingInstructionHandler() { + $called = false; + $testXML = new XmlTypeCheck( + self::XML_WITH_PIH, + null, + false, + [ + 'processing_instruction_handler' => function () use ( &$called ) { + $called = true; + } + ] + ); + $this->assertTrue( $called ); + } + +} diff --git a/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php b/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php new file mode 100644 index 0000000000..58e617ca82 --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerInstalledTest.php @@ -0,0 +1,498 @@ +installed = __DIR__ . "/../../../data/composer/installed.json"; + } + + /** + * @covers ComposerInstalled::__construct + * @covers ComposerInstalled::getInstalledDependencies + */ + public function testGetInstalledDependencies() { + $installed = new ComposerInstalled( $this->installed ); + $this->assertEquals( [ + 'leafo/lessphp' => [ + 'version' => '0.5.0', + 'type' => 'library', + 'licenses' => [ 'MIT', 'GPL-3.0-only' ], + 'authors' => [ + [ + 'name' => 'Leaf Corcoran', + 'email' => 'leafot@gmail.com', + 'homepage' => 'http://leafo.net', + ], + ], + 'description' => 'lessphp is a compiler for LESS written in PHP.', + ], + 'psr/log' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'PHP-FIG', + 'homepage' => 'http://www.php-fig.org/', + ], + ], + 'description' => 'Common interface for logging libraries', + ], + 'cssjanus/cssjanus' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'Apache-2.0' ], + 'authors' => [ + ], + 'description' => 'Convert CSS stylesheets between left-to-right ' . + 'and right-to-left.', + ], + 'cdb/cdb' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'GPLv2' ], + 'authors' => [ + [ + 'name' => 'Tim Starling', + 'email' => 'tstarling@wikimedia.org', + ], + [ + 'name' => 'Chad Horohoe', + 'email' => 'chad@wikimedia.org', + ], + ], + 'description' => 'Constant Database (CDB) wrapper library for PHP. ' . + 'Provides pure-PHP fallback when dba_* functions are absent.', + ], + 'sebastian/version' => [ + 'version' => '2.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Library that helps with managing the version ' . + 'number of Git-hosted PHP projects', + ], + 'sebastian/resource-operations' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides a list of PHP built-in functions that ' . + 'operate on resources', + ], + 'sebastian/recursion-context' => [ + 'version' => '3.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + [ + 'name' => 'Adam Harvey', + 'email' => 'aharvey@php.net', + ], + ], + 'description' => 'Provides functionality to recursively process PHP ' . + 'variables', + ], + 'sebastian/object-reflector' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Allows reflection of object attributes, including ' . + 'inherited and non-public ones', + ], + 'sebastian/object-enumerator' => [ + 'version' => '3.0.3', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Traverses array structures and object graphs ' . + 'to enumerate all referenced objects', + ], + 'sebastian/global-state' => [ + 'version' => '2.0.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Snapshotting of global state', + ], + 'sebastian/exporter' => [ + 'version' => '3.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Volker Dusch', + 'email' => 'github@wallbash.com', + ], + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@2bepublished.at', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + [ + 'name' => 'Adam Harvey', + 'email' => 'aharvey@php.net', + ], + ], + 'description' => 'Provides the functionality to export PHP ' . + 'variables for visualization', + ], + 'sebastian/environment' => [ + 'version' => '3.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides functionality to handle HHVM/PHP ' . + 'environments', + ], + 'sebastian/diff' => [ + 'version' => '2.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Kore Nordmann', + 'email' => 'mail@kore-nordmann.de', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Diff implementation', + ], + 'sebastian/comparator' => [ + 'version' => '2.1.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Jeff Welch', + 'email' => 'whatthejeff@gmail.com', + ], + [ + 'name' => 'Volker Dusch', + 'email' => 'github@wallbash.com', + ], + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@2bepublished.at', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Provides the functionality to compare PHP ' . + 'values for equality', + ], + 'doctrine/instantiator' => [ + 'version' => '1.1.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Marco Pivetta', + 'email' => 'ocramius@gmail.com', + 'homepage' => 'http://ocramius.github.com/', + ], + ], + 'description' => 'A small, lightweight utility to instantiate ' . + 'objects in PHP without invoking their constructors', + ], + 'phpunit/php-text-template' => [ + 'version' => '1.2.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Simple template engine.', + ], + 'phpunit/phpunit-mock-objects' => [ + 'version' => '5.0.6', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Mock Object library for PHPUnit', + ], + 'phpunit/php-timer' => [ + 'version' => '1.0.9', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sb@sebastian-bergmann.de', + 'role' => 'lead', + ], + ], + 'description' => 'Utility class for timing', + ], + 'phpunit/php-file-iterator' => [ + 'version' => '1.4.5', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sb@sebastian-bergmann.de', + 'role' => 'lead', + ], + ], + 'description' => 'FilterIterator implementation that filters ' . + 'files based on a list of suffixes.', + ], + 'theseer/tokenizer' => [ + 'version' => '1.1.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + ], + 'description' => 'A small library for converting tokenized PHP ' . + 'source code into XML and potentially other formats', + ], + 'sebastian/code-unit-reverse-lookup' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Looks up which function or method a line of ' . + 'code belongs to', + ], + 'phpunit/php-token-stream' => [ + 'version' => '2.0.2', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + ], + ], + 'description' => 'Wrapper around PHP\'s tokenizer extension.', + ], + 'phpunit/php-code-coverage' => [ + 'version' => '5.3.0', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'Library that provides collection, processing, ' . + 'and rendering functionality for PHP code coverage information.', + ], + 'webmozart/assert' => [ + 'version' => '1.2.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Bernhard Schussek', + 'email' => 'bschussek@gmail.com', + ], + ], + 'description' => 'Assertions to validate method input/output with ' . + 'nice error messages.', + ], + 'phpdocumentor/reflection-common' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Jaap van Otterdijk', + 'email' => 'opensource@ijaap.nl', + ], + ], + 'description' => 'Common reflection classes used by phpdocumentor to ' . + 'reflect the code structure', + ], + 'phpdocumentor/type-resolver' => [ + 'version' => '0.4.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Mike van Riel', + 'email' => 'me@mikevanriel.com', + ], + ], + 'description' => '', + ], + 'phpdocumentor/reflection-docblock' => [ + 'version' => '4.2.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Mike van Riel', + 'email' => 'me@mikevanriel.com', + ], + ], + 'description' => 'With this component, a library can provide support for ' . + 'annotations via DocBlocks or otherwise retrieve information that ' . + 'is embedded in a DocBlock.', + ], + 'phpspec/prophecy' => [ + 'version' => '1.7.3', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Konstantin Kudryashov', + 'email' => 'ever.zet@gmail.com', + 'homepage' => 'http://everzet.com', + ], + [ + 'name' => 'Marcello Duarte', + 'email' => 'marcello.duarte@gmail.com', + ], + ], + 'description' => 'Highly opinionated mocking framework for PHP 5.3+', + ], + 'phar-io/version' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Heuer', + 'email' => 'sebastian@phpeople.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'Developer', + ], + ], + 'description' => 'Library for handling version information and constraints', + ], + 'phar-io/manifest' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Arne Blankerts', + 'email' => 'arne@blankerts.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Heuer', + 'email' => 'sebastian@phpeople.de', + 'role' => 'Developer', + ], + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'Developer', + ], + ], + 'description' => 'Component for reading phar.io manifest ' . + 'information from a PHP Archive (PHAR)', + ], + 'myclabs/deep-copy' => [ + 'version' => '1.7.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + ], + 'description' => 'Create deep copies (clones) of your objects', + ], + 'phpunit/phpunit' => [ + 'version' => '6.5.5', + 'type' => 'library', + 'licenses' => [ 'BSD-3-Clause' ], + 'authors' => [ + [ + 'name' => 'Sebastian Bergmann', + 'email' => 'sebastian@phpunit.de', + 'role' => 'lead', + ], + ], + 'description' => 'The PHP Unit Testing framework.', + ], + ], $installed->getInstalledDependencies() ); + } +} diff --git a/tests/phpunit/includes/libs/composer/ComposerJsonTest.php b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php new file mode 100644 index 0000000000..720fa6e8fd --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerJsonTest.php @@ -0,0 +1,41 @@ +json = __DIR__ . "/../../../data/composer/composer.json"; + $this->json2 = __DIR__ . "/../../../data/composer/new-composer.json"; + } + + /** + * @covers ComposerJson::__construct + * @covers ComposerJson::getRequiredDependencies + */ + public function testGetRequiredDependencies() { + $json = new ComposerJson( $this->json ); + $this->assertEquals( [ + 'cdb/cdb' => '1.0.0', + 'cssjanus/cssjanus' => '1.1.1', + 'leafo/lessphp' => '0.5.0', + 'psr/log' => '1.0.0', + ], $json->getRequiredDependencies() ); + } + + public static function provideNormalizeVersion() { + return [ + [ 'v1.0.0', '1.0.0' ], + [ '0.0.5', '0.0.5' ], + ]; + } + + /** + * @dataProvider provideNormalizeVersion + * @covers ComposerJson::normalizeVersion + */ + public function testNormalizeVersion( $input, $expected ) { + $this->assertEquals( $expected, ComposerJson::normalizeVersion( $input ) ); + } +} diff --git a/tests/phpunit/includes/libs/composer/ComposerLockTest.php b/tests/phpunit/includes/libs/composer/ComposerLockTest.php new file mode 100644 index 0000000000..f5fcdbe018 --- /dev/null +++ b/tests/phpunit/includes/libs/composer/ComposerLockTest.php @@ -0,0 +1,120 @@ +lock = __DIR__ . "/../../../data/composer/composer.lock"; + } + + /** + * @covers ComposerLock::__construct + * @covers ComposerLock::getInstalledDependencies + */ + public function testGetInstalledDependencies() { + $lock = new ComposerLock( $this->lock ); + $this->assertEquals( [ + 'wikimedia/cdb' => [ + 'version' => '1.0.1', + 'type' => 'library', + 'licenses' => [ 'GPL-2.0-only' ], + 'authors' => [ + [ + 'name' => 'Tim Starling', + 'email' => 'tstarling@wikimedia.org', + ], + [ + 'name' => 'Chad Horohoe', + 'email' => 'chad@wikimedia.org', + ], + ], + 'description' => 'Constant Database (CDB) wrapper library for PHP. ' . + 'Provides pure-PHP fallback when dba_* functions are absent.', + ], + 'cssjanus/cssjanus' => [ + 'version' => '1.1.1', + 'type' => 'library', + 'licenses' => [ 'Apache-2.0' ], + 'authors' => [], + 'description' => 'Convert CSS stylesheets between left-to-right and right-to-left.', + ], + 'leafo/lessphp' => [ + 'version' => '0.5.0', + 'type' => 'library', + 'licenses' => [ 'MIT', 'GPL-3.0-only' ], + 'authors' => [ + [ + 'name' => 'Leaf Corcoran', + 'email' => 'leafot@gmail.com', + 'homepage' => 'http://leafo.net', + ], + ], + 'description' => 'lessphp is a compiler for LESS written in PHP.', + ], + 'psr/log' => [ + 'version' => '1.0.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'PHP-FIG', + 'homepage' => 'http://www.php-fig.org/', + ], + ], + 'description' => 'Common interface for logging libraries', + ], + 'oojs/oojs-ui' => [ + 'version' => '0.6.0', + 'type' => 'library', + 'licenses' => [ 'MIT' ], + 'authors' => [], + 'description' => '', + ], + 'composer/installers' => [ + 'version' => '1.0.19', + 'type' => 'composer-installer', + 'licenses' => [ 'MIT' ], + 'authors' => [ + [ + 'name' => 'Kyle Robinson Young', + 'email' => 'kyle@dontkry.com', + 'homepage' => 'https://github.com/shama', + ], + ], + 'description' => 'A multi-framework Composer library installer', + ], + 'mediawiki/translate' => [ + 'version' => '2014.12', + 'type' => 'mediawiki-extension', + 'licenses' => [ 'GPL-2.0-or-later' ], + 'authors' => [ + [ + 'name' => 'Niklas Laxström', + 'email' => 'niklas.laxstrom@gmail.com', + 'role' => 'Lead nitpicker', + ], + [ + 'name' => 'Siebrand Mazeland', + 'email' => 's.mazeland@xs4all.nl', + 'role' => 'Developer', + ], + ], + 'description' => 'The only standard solution to translate any kind ' . + 'of text with an avant-garde web interface within MediaWiki, ' . + 'including your documentation and software', + ], + 'mediawiki/universal-language-selector' => [ + 'version' => '2014.12', + 'type' => 'mediawiki-extension', + 'licenses' => [ 'GPL-2.0-or-later', 'MIT' ], + 'authors' => [], + 'description' => 'The primary aim is to allow users to select a language ' . + 'and configure its support in an easy way. ' . + 'Main features are language selection, input methods and web fonts.', + ], + ], $lock->getInstalledDependencies() ); + } + +} diff --git a/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php b/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php new file mode 100644 index 0000000000..02eac11887 --- /dev/null +++ b/tests/phpunit/includes/libs/http/HttpAcceptNegotiatorTest.php @@ -0,0 +1,150 @@ +getFirstSupportedValue( $accepted, $default ); + + $this->assertEquals( $expected, $actual ); + } + + public function provideGetBestSupportedKey() { + return [ + [ // #0: empty + [], // supported + [], // accepted + null, // default + null, // expected + ], + [ // #1: simple + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy' => 1, 'text/bar' => 0.5 ], // accepted + null, // default + 'text/BAR', // expected + ], + [ // #2: default + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xzy' => 1, 'text/xoo' => 0.5 ], // accepted + 'X', // default + 'X', // expected + ], + [ // #3: weighted + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0.3, 'text/BAR' => 0.8, 'application/zuul' => 0.5 ], // accepted + null, // default + 'text/BAR', // expected + ], + [ // #4: zero weight + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0, 'text/xoo' => 1 ], // accepted + null, // default + null, // expected + ], + [ // #5: * wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo' => 0.5, '*' => 0.1 ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #6: */* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/xoo' => 0.5, '*/*' => 0.1 ], // accepted + null, // default + 'text/foo', // expected + ], + [ // #7: text/* wildcard + [ 'text/foo', 'text/BAR', 'application/zuul' ], // supported + [ 'text/foo' => 0.3, 'application/*' => 0.8 ], // accepted + null, // default + 'application/zuul', // expected + ], + [ // #8: Test specific format preferred over wildcard (T133314) + [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported + [ '*/*' => 1, 'text/html' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + [ // #9: Test specific format preferred over range (T133314) + [ 'application/rdf+xml', 'text/json', 'text/html' ], // supported + [ 'text/*' => 1, 'text/html' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + [ // #10: Test range preferred over wildcard (T133314) + [ 'application/rdf+xml', 'text/html' ], // supported + [ '*/*' => 1, 'text/*' => 1 ], // accepted + null, // default + 'text/html', // expected + ], + ]; + } + + /** + * @dataProvider provideGetBestSupportedKey + */ + public function testGetBestSupportedKey( $supported, $accepted, $default, $expected ) { + $negotiator = new HttpAcceptNegotiator( $supported ); + $actual = $negotiator->getBestSupportedKey( $accepted, $default ); + + $this->assertEquals( $expected, $actual ); + } + +} diff --git a/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php new file mode 100644 index 0000000000..e4b47b46d5 --- /dev/null +++ b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php @@ -0,0 +1,56 @@ + 1 ] + ], + [ // #2 + 'Accept: text/plain', + [ 'text/plain' => 1 ] + ], + [ // #3 + 'Accept: application/vnd.php.serialized, application/rdf+xml', + [ 'application/vnd.php.serialized' => 1, 'application/rdf+xml' => 1 ] + ], + [ // #4 + 'foo; q=0.2, xoo; q=0,text/n3', + [ 'text/n3' => 1, 'foo' => 0.2 ] + ], + [ // #5 + '*; q=0.2, */*; q=0.1,text/*', + [ 'text/*' => 1, '*' => 0.2, '*/*' => 0.1 ] + ], + // TODO: nicely ignore additional type paramerters + //[ // #6 + // 'Foo; q=0.2, Xoo; level=3, Bar; charset=xyz; q=0.4', + // [ 'xoo' => 1, 'bar' => 0.4, 'foo' => 0.1 ] + //], + ]; + } + + /** + * @dataProvider provideParseWeights + */ + public function testParseWeights( $header, $expected ) { + $parser = new HttpAcceptParser(); + $actual = $parser->parseWeights( $header ); + + $this->assertEquals( $expected, $actual ); // shouldn't be sensitive to order + } + +} diff --git a/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php b/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php new file mode 100644 index 0000000000..4509a61eb7 --- /dev/null +++ b/tests/phpunit/includes/libs/mime/MSCompoundFileReaderTest.php @@ -0,0 +1,60 @@ +assertTrue( $info['valid'] ); + $this->assertSame( $expectedMime, $info['mime'] ); + } + + public static function provideInvalid() { + return [ + [ 'dir-beyond-end.xls', 'ERROR_READ_PAST_END' ], + [ 'fat-loop.xls', 'ERROR_INVALID_FORMAT' ], + [ 'invalid-signature.xls', 'ERROR_INVALID_SIGNATURE' ], + ]; + } + + /** @dataProvider provideInvalid */ + public function testReadFileInvalid( $fileName, $expectedError ) { + global $IP; + + $info = MSCompoundFileReader::readFile( "$IP/tests/phpunit/data/MSCompoundFileReader/$fileName" ); + $this->assertFalse( $info['valid'] ); + $this->assertSame( constant( MSCompoundFileReader::class . '::' . $expectedError ), + $info['errorCode'] ); + } +} diff --git a/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php new file mode 100644 index 0000000000..194781207e --- /dev/null +++ b/tests/phpunit/includes/libs/mime/MimeAnalyzerTest.php @@ -0,0 +1,140 @@ +mimeAnalyzer = new MimeAnalyzer( [ + 'infoFile' => $IP . "/includes/libs/mime/mime.info", + 'typeFile' => $IP . "/includes/libs/mime/mime.types", + 'xmlTypes' => [ + 'http://www.w3.org/2000/svg:svg' => 'image/svg+xml', + 'svg' => 'image/svg+xml', + 'http://www.lysator.liu.se/~alla/dia/:diagram' => 'application/x-dia-diagram', + 'http://www.w3.org/1999/xhtml:html' => 'text/html', // application/xhtml+xml? + 'html' => 'text/html', // application/xhtml+xml? + ] + ] ); + parent::setUp(); + } + + function doGuessMimeType( array $parameters = [] ) { + $class = new ReflectionClass( get_class( $this->mimeAnalyzer ) ); + $method = $class->getMethod( 'doGuessMimeType' ); + $method->setAccessible( true ); + return $method->invokeArgs( $this->mimeAnalyzer, $parameters ); + } + + /** + * @dataProvider providerImproveTypeFromExtension + * @param string $ext File extension (no leading dot) + * @param string $oldMime Initially detected MIME + * @param string $expectedMime MIME type after taking extension into account + */ + function testImproveTypeFromExtension( $ext, $oldMime, $expectedMime ) { + $actualMime = $this->mimeAnalyzer->improveTypeFromExtension( $oldMime, $ext ); + $this->assertEquals( $expectedMime, $actualMime ); + } + + function providerImproveTypeFromExtension() { + return [ + [ 'gif', 'image/gif', 'image/gif' ], + [ 'gif', 'unknown/unknown', 'unknown/unknown' ], + [ 'wrl', 'unknown/unknown', 'model/vrml' ], + [ 'txt', 'text/plain', 'text/plain' ], + [ 'csv', 'text/plain', 'text/csv' ], + [ 'tsv', 'text/plain', 'text/tab-separated-values' ], + [ 'js', 'text/javascript', 'application/javascript' ], + [ 'js', 'application/x-javascript', 'application/javascript' ], + [ 'json', 'text/plain', 'application/json' ], + [ 'foo', 'application/x-opc+zip', 'application/zip' ], + [ 'docx', 'application/x-opc+zip', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ], + [ 'djvu', 'image/x-djvu', 'image/vnd.djvu' ], + [ 'wav', 'audio/wav', 'audio/wav' ], + ]; + } + + /** + * Test to make sure that encoder=ffmpeg2theora doesn't trigger + * MEDIATYPE_VIDEO (T65584) + */ + function testOggRecognize() { + $oggFile = __DIR__ . '/../../../data/media/say-test.ogg'; + $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that Opus audio files don't trigger + * MEDIATYPE_MULTIMEDIA (bug T151352) + */ + function testOpusRecognize() { + $oggFile = __DIR__ . '/../../../data/media/say-test.opus'; + $actualType = $this->mimeAnalyzer->getMediaType( $oggFile, 'application/ogg' ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that mp3 files are detected as audio type + */ + function testMP3AsAudio() { + $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3'; + $actualType = $this->mimeAnalyzer->getMediaType( $file ); + $this->assertEquals( MEDIATYPE_AUDIO, $actualType ); + } + + /** + * Test to make sure that MP3 with id3 tag is recognized + */ + function testMP3WithID3Recognize() { + $file = __DIR__ . '/../../../data/media/say-test-with-id3.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-1 sample rates) + */ + function testMP3NoID3RecognizeMPEG1() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg1.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-2 sample rates) + */ + function testMP3NoID3RecognizeMPEG2() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg2.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * Test to make sure that MP3 without id3 tag is recognized (MPEG-2.5 sample rates) + */ + function testMP3NoID3RecognizeMPEG2_5() { + $file = __DIR__ . '/../../../data/media/say-test-mpeg2.5.mp3'; + $actualType = $this->doGuessMimeType( [ $file, 'mp3' ] ); + $this->assertEquals( 'audio/mpeg', $actualType ); + } + + /** + * A ZIP file embedded in the middle of a .doc file is still a Word Document. + */ + function testZipInDoc() { + $file = __DIR__ . '/../../../data/media/zip-in-doc.doc'; + $actualType = $this->doGuessMimeType( [ $file, 'doc' ] ); + $this->assertEquals( 'application/msword', $actualType ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php new file mode 100644 index 0000000000..f953319e67 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php @@ -0,0 +1,159 @@ +set( 'foo', 'bar' ); + $this->assertEquals( 'bar', $cache->get( 'foo' ) ); + + $backend->set( 'foo', 'baz' ); + $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' ); + } + + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ + public function testSetAndDelete() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $this->assertEquals( 1, $backend->get( "key$i" ) ); + + $cache->delete( "key$i" ); + $this->assertEquals( false, $cache->get( "key$i" ) ); + $this->assertEquals( false, $backend->get( "key$i" ) ); + } + } + + /** + * @covers CachedBagOStuff::set + * @covers CachedBagOStuff::delete + */ + public function testWriteCacheOnly() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + $cache->set( 'foo', 'bar', 0, CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'bar', $cache->get( 'foo' ) ); + $this->assertFalse( $backend->get( 'foo' ) ); + + $cache->set( 'foo', 'old' ); + $this->assertEquals( 'old', $cache->get( 'foo' ) ); + $this->assertEquals( 'old', $backend->get( 'foo' ) ); + + $cache->set( 'foo', 'new', 0, CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'new', $cache->get( 'foo' ) ); + $this->assertEquals( 'old', $backend->get( 'foo' ) ); + + $cache->delete( 'foo', CachedBagOStuff::WRITE_CACHE_ONLY ); + $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend + } + + /** + * @covers CachedBagOStuff::get + */ + public function testCacheBackendMisses() { + $backend = new HashBagOStuff; + $cache = new CachedBagOStuff( $backend ); + + // First hit primes the cache with miss from the backend + $this->assertEquals( false, $cache->get( 'foo' ) ); + + // Change the value in the backend + $backend->set( 'foo', true ); + + // Second hit returns the cached miss + $this->assertEquals( false, $cache->get( 'foo' ) ); + + // But a fresh value is read from the backend + $backend->set( 'bar', true ); + $this->assertEquals( true, $cache->get( 'bar' ) ); + } + + /** + * @covers CachedBagOStuff::setDebug + */ + public function testSetDebug() { + $backend = new HashBagOStuff(); + $cache = new CachedBagOStuff( $backend ); + // Access private property 'debugMode' + $backend = TestingAccessWrapper::newFromObject( $backend ); + $cache = TestingAccessWrapper::newFromObject( $cache ); + $this->assertFalse( $backend->debugMode ); + $this->assertFalse( $cache->debugMode ); + + $cache->setDebug( true ); + // Should have set both + $this->assertTrue( $backend->debugMode, 'sets backend' ); + $this->assertTrue( $cache->debugMode, 'sets self' ); + } + + /** + * @covers CachedBagOStuff::deleteObjectsExpiringBefore + */ + public function testExpire() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'deleteObjectsExpiringBefore' ] ) + ->getMock(); + $backend->expects( $this->once() ) + ->method( 'deleteObjectsExpiringBefore' ) + ->willReturn( false ); + + $cache = new CachedBagOStuff( $backend ); + $cache->deleteObjectsExpiringBefore( '20110401000000' ); + } + + /** + * @covers CachedBagOStuff::makeKey + */ + public function testMakeKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeKey' ] ) + ->getMock(); + $backend->method( 'makeKey' ) + ->willReturn( 'special/logic' ); + + // CachedBagOStuff wraps any backend with a process cache + // using HashBagOStuff. Hash has no special key limitations, + // but backends often do. Make sure it uses the backend's + // makeKey() logic, not the one inherited from HashBagOStuff + $cache = new CachedBagOStuff( $backend ); + + $this->assertEquals( 'special/logic', $backend->makeKey( 'special', 'logic' ) ); + $this->assertEquals( 'special/logic', $cache->makeKey( 'special', 'logic' ) ); + } + + /** + * @covers CachedBagOStuff::makeGlobalKey + */ + public function testMakeGlobalKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeGlobalKey' ] ) + ->getMock(); + $backend->method( 'makeGlobalKey' ) + ->willReturn( 'special/logic' ); + + $cache = new CachedBagOStuff( $backend ); + + $this->assertEquals( 'special/logic', $backend->makeGlobalKey( 'special', 'logic' ) ); + $this->assertEquals( 'special/logic', $cache->makeGlobalKey( 'special', 'logic' ) ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php new file mode 100644 index 0000000000..332e23b25b --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/HashBagOStuffTest.php @@ -0,0 +1,163 @@ +assertInstanceOf( + HashBagOStuff::class, + new HashBagOStuff() + ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadZero() { + $cache = new HashBagOStuff( [ 'maxKeys' => 0 ] ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadNeg() { + $cache = new HashBagOStuff( [ 'maxKeys' => -1 ] ); + } + + /** + * @covers HashBagOStuff::__construct + * @expectedException InvalidArgumentException + */ + public function testConstructBadType() { + $cache = new HashBagOStuff( [ 'maxKeys' => 'x' ] ); + } + + /** + * @covers HashBagOStuff::delete + */ + public function testDelete() { + $cache = new HashBagOStuff(); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $cache->delete( "key$i" ); + $this->assertEquals( false, $cache->get( "key$i" ) ); + } + } + + /** + * @covers HashBagOStuff::clear + */ + public function testClear() { + $cache = new HashBagOStuff(); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + } + $cache->clear(); + for ( $i = 0; $i < 10; $i++ ) { + $this->assertEquals( false, $cache->get( "key$i" ) ); + } + } + + /** + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::expire + */ + public function testExpire() { + $cache = new HashBagOStuff(); + $cacheInternal = TestingAccessWrapper::newFromObject( $cache ); + $cache->set( 'foo', 1 ); + $cache->set( 'bar', 1, 10 ); + $cache->set( 'baz', 1, -10 ); + + $this->assertEquals( 0, $cacheInternal->bag['foo'][$cache::KEY_EXP], 'Indefinite' ); + // 2 seconds tolerance + $this->assertEquals( time() + 10, $cacheInternal->bag['bar'][$cache::KEY_EXP], 'Future', 2 ); + $this->assertEquals( time() - 10, $cacheInternal->bag['baz'][$cache::KEY_EXP], 'Past', 2 ); + + $this->assertEquals( 1, $cache->get( 'bar' ), 'Key not expired' ); + $this->assertEquals( false, $cache->get( 'baz' ), 'Key expired' ); + } + + /** + * Ensure maxKeys eviction prefers keeping new keys. + * + * @covers HashBagOStuff::set + */ + public function testEvictionAdd() { + $cache = new HashBagOStuff( [ 'maxKeys' => 10 ] ); + for ( $i = 0; $i < 10; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + } + for ( $i = 10; $i < 20; $i++ ) { + $cache->set( "key$i", 1 ); + $this->assertEquals( 1, $cache->get( "key$i" ) ); + $this->assertEquals( false, $cache->get( "key" . ( $i - 10 ) ) ); + } + } + + /** + * Ensure maxKeys eviction prefers recently set keys + * even if the keys pre-exist. + * + * @covers HashBagOStuff::set + */ + public function testEvictionSet() { + $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); + + foreach ( [ 'foo', 'bar', 'baz' ] as $key ) { + $cache->set( $key, 1 ); + } + + // Set existing key + $cache->set( 'foo', 1 ); + + // Add a 4th key (beyond the allowed maximum) + $cache->set( 'quux', 1 ); + + // Foo's life should have been extended over Bar + foreach ( [ 'foo', 'baz', 'quux' ] as $key ) { + $this->assertEquals( 1, $cache->get( $key ), "Kept $key" ); + } + $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' ); + } + + /** + * Ensure maxKeys eviction prefers recently retrieved keys (LRU). + * + * @covers HashBagOStuff::doGet + * @covers HashBagOStuff::hasKey + */ + public function testEvictionGet() { + $cache = new HashBagOStuff( [ 'maxKeys' => 3 ] ); + + foreach ( [ 'foo', 'bar', 'baz' ] as $key ) { + $cache->set( $key, 1 ); + } + + // Get existing key + $cache->get( 'foo', 1 ); + + // Add a 4th key (beyond the allowed maximum) + $cache->set( 'quux', 1 ); + + // Foo's life should have been extended over Bar + foreach ( [ 'foo', 'baz', 'quux' ] as $key ) { + $this->assertEquals( 1, $cache->get( $key ), "Kept $key" ); + } + $this->assertEquals( false, $cache->get( 'bar' ), 'Evicted bar' ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php new file mode 100644 index 0000000000..550ec0bd09 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/ReplicatedBagOStuffTest.php @@ -0,0 +1,62 @@ +writeCache = new HashBagOStuff(); + $this->readCache = new HashBagOStuff(); + $this->cache = new ReplicatedBagOStuff( [ + 'writeFactory' => $this->writeCache, + 'readFactory' => $this->readCache, + ] ); + } + + /** + * @covers ReplicatedBagOStuff::set + */ + public function testSet() { + $key = 'a key'; + $value = 'a value'; + $this->cache->set( $key, $value ); + + // Write to master. + $this->assertEquals( $value, $this->writeCache->get( $key ) ); + // Don't write to replica. Replication is deferred to backend. + $this->assertFalse( $this->readCache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGet() { + $key = 'a key'; + + $write = 'one value'; + $this->writeCache->set( $key, $write ); + $read = 'another value'; + $this->readCache->set( $key, $read ); + + // Read from replica. + $this->assertEquals( $read, $this->cache->get( $key ) ); + } + + /** + * @covers ReplicatedBagOStuff::get + */ + public function testGetAbsent() { + $key = 'a key'; + $value = 'a value'; + $this->writeCache->set( $key, $value ); + + // Don't read from master. No failover if value is absent. + $this->assertFalse( $this->cache->get( $key ) ); + } +} diff --git a/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php new file mode 100644 index 0000000000..017d745e49 --- /dev/null +++ b/tests/phpunit/includes/libs/objectcache/WANObjectCacheTest.php @@ -0,0 +1,1867 @@ +cache = new WANObjectCache( [ + 'cache' => new HashBagOStuff() + ] ); + + $wanCache = TestingAccessWrapper::newFromObject( $this->cache ); + /** @noinspection PhpUndefinedFieldInspection */ + $this->internalCache = $wanCache->cache; + } + + /** + * @dataProvider provideSetAndGet + * @covers WANObjectCache::set() + * @covers WANObjectCache::get() + * @covers WANObjectCache::makeKey() + * @param mixed $value + * @param int $ttl + */ + public function testSetAndGet( $value, $ttl ) { + $curTTL = null; + $asOf = null; + $key = $this->cache->makeKey( 'x', wfRandomString() ); + + $this->cache->get( $key, $curTTL, [], $asOf ); + $this->assertNull( $curTTL, "Current TTL is null" ); + $this->assertNull( $asOf, "Current as-of-time is infinite" ); + + $t = microtime( true ); + $this->cache->set( $key, $value, $ttl ); + + $this->assertEquals( $value, $this->cache->get( $key, $curTTL, [], $asOf ) ); + if ( is_infinite( $ttl ) || $ttl == 0 ) { + $this->assertTrue( is_infinite( $curTTL ), "Current TTL is infinite" ); + } else { + $this->assertGreaterThan( 0, $curTTL, "Current TTL > 0" ); + $this->assertLessThanOrEqual( $ttl, $curTTL, "Current TTL < nominal TTL" ); + } + $this->assertGreaterThanOrEqual( $t - 1, $asOf, "As-of-time in range of set() time" ); + $this->assertLessThanOrEqual( $t + 1, $asOf, "As-of-time in range of set() time" ); + } + + public static function provideSetAndGet() { + return [ + [ 14141, 3 ], + [ 3535.666, 3 ], + [ [], 3 ], + [ null, 3 ], + [ '0', 3 ], + [ (object)[ 'meow' ], 3 ], + [ INF, 3 ], + [ '', 3 ], + [ 'pizzacat', INF ], + ]; + } + + /** + * @covers WANObjectCache::get() + * @covers WANObjectCache::makeGlobalKey() + */ + public function testGetNotExists() { + $key = $this->cache->makeGlobalKey( 'y', wfRandomString(), 'p' ); + $curTTL = null; + $value = $this->cache->get( $key, $curTTL ); + + $this->assertFalse( $value, "Non-existing key has false value" ); + $this->assertNull( $curTTL, "Non-existing key has null current TTL" ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testSetOver() { + $key = wfRandomString(); + for ( $i = 0; $i < 3; ++$i ) { + $value = wfRandomString(); + $this->cache->set( $key, $value, 3 ); + + $this->assertEquals( $this->cache->get( $key ), $value ); + } + } + + /** + * @covers WANObjectCache::set() + */ + public function testStaleSet() { + $key = wfRandomString(); + $value = wfRandomString(); + $this->cache->set( $key, $value, 3, [ 'since' => microtime( true ) - 30 ] ); + + $this->assertFalse( $this->cache->get( $key ), "Stale set() value ignored" ); + } + + public function testProcessCache() { + $mockWallClock = 1549343530.2053; + $this->cache->setMockTime( $mockWallClock ); + + $hit = 0; + $callback = function () use ( &$hit ) { + ++$hit; + return 42; + }; + $keys = [ wfRandomString(), wfRandomString(), wfRandomString() ]; + $groups = [ 'thiscache:1', 'thatcache:1', 'somecache:1' ]; + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 3, $hit, "Values cached" ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit ); + + foreach ( $keys as $i => $key ) { + $this->cache->getWithSetCallback( + "$key-2", 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 6, $hit, "New values cached" ); + + foreach ( $keys as $i => $key ) { + // Should evict from process cache + $this->cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + // Get into cache (specific process cache group) + $this->cache->getWithSetCallback( + $key, 100, $callback, [ 'pcTTL' => 5, 'pcGroup' => $groups[$i] ] ); + } + $this->assertEquals( 9, $hit, "Values evicted by delete()" ); + + // Get into cache (default process cache group) + $key = reset( $keys ); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 9, $hit, "Value recently interim-cached" ); + + $mockWallClock += 0.2; // interim key not brand new + $this->cache->clearProcessCache(); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 10, $hit, "Value calculated (interim key not recent and reset)" ); + $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + $this->assertEquals( 10, $hit, "Value process cached" ); + + $mockWallClock += 0.2; // interim key not brand new + $outerCallback = function () use ( &$callback, $key ) { + $v = $this->cache->getWithSetCallback( $key, 100, $callback, [ 'pcTTL' => 5 ] ); + + return 43 + $v; + }; + // Outer key misses and refuses inner key process cache value + $this->cache->getWithSetCallback( "$key-miss-outer", 100, $outerCallback ); + $this->assertEquals( 11, $hit, "Nested callback value process cache skipped" ); + } + + /** + * @dataProvider getWithSetCallback_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetWithSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $key = wfRandomString(); + $value = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $priorValue = null; + $priorAsOf = null; + $wasSet = 0; + $func = function ( $old, &$ttl, &$opts, $asOf ) + use ( &$wasSet, &$priorValue, &$priorAsOf, $value ) { + ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; + $ttl = 20; // override with another value + return $value; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); + + $curTTL = null; + $cache->get( $key, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'lowTTL' => 0, 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 0, $wasSet, "Value not regenerated" ); + + $mockWallClock += 1; + + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.2; // interim key is not brand new and check keys have past values + $priorTime = $mockWallClock; // reference time + $wasSet = 0; + $v = $cache->getWithSetCallback( + $key, 30, $func, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value returned" ); + $cache->delete( $key ); + $v = $cache->getWithSetCallback( $key, 30, $func, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v, "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $oldValReceived = -1; + $oldAsOfReceived = -1; + $checkFunc = function ( $oldVal, &$ttl, array $setOpts, $oldAsOf ) + use ( &$oldValReceived, &$oldAsOfReceived, &$wasSet ) { + ++$wasSet; + $oldValReceived = $oldVal; + $oldAsOfReceived = $oldAsOf; + + return 'xxx' . $wasSet; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + + $wasSet = 0; + $key = wfRandomString(); + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx1', $v, "Value returned" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock += 40; + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx2', $v, "Value still returned after expired" ); + $this->assertEquals( 2, $wasSet, "Value recalculated while expired" ); + $this->assertEquals( 'xxx1', $oldValReceived, "Callback got stale value" ); + $this->assertNotEquals( null, $oldAsOfReceived, "Callback got stale value" ); + + $mockWallClock += 260; + $v = $cache->getWithSetCallback( + $key, 30, $checkFunc, [ 'staleTTL' => 50 ] + $extOpts ); + $this->assertEquals( 'xxx3', $v, "Value still returned after expired" ); + $this->assertEquals( 3, $wasSet, "Value recalculated while expired" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock = ( $priorTime - $cache::HOLDOFF_TTL - 1 ); + $wasSet = 0; + $key = wfRandomString(); + $checkKey = $cache->makeKey( 'template', 'X' ); + $cache->touchCheckKey( $checkKey ); // init check key + $mockWallClock = $priorTime; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value computed" ); + $this->assertEquals( false, $oldValReceived, "Callback got no stale value" ); + $this->assertEquals( null, $oldAsOfReceived, "Callback got no stale value" ); + + $mockWallClock += $cache::TTL_HOUR; // some time passes + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Cached value returned" ); + $this->assertEquals( 1, $wasSet, "Cached value returned" ); + + $cache->touchCheckKey( $checkKey ); // make key stale + $mockWallClock += 0.01; // ~1 week left of grace (barely stale to avoid refreshes) + + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value still returned after expired (in grace)" ); + $this->assertEquals( 1, $wasSet, "Value still returned after expired (in grace)" ); + + // Chance of refresh increase to unity as staleness approaches graceTTL + $mockWallClock += $cache::TTL_WEEK; // 8 days of being stale + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'graceTTL' => $cache::TTL_WEEK, 'checkKeys' => [ $checkKey ] ] + $extOpts + ); + $this->assertEquals( 'xxx2', $v, "Value was recomputed (past grace)" ); + $this->assertEquals( 2, $wasSet, "Value was recomputed (past grace)" ); + $this->assertEquals( 'xxx1', $oldValReceived, "Callback got post-grace stale value" ); + $this->assertNotEquals( null, $oldAsOfReceived, "Callback got post-grace stale value" ); + } + + /** + * @dataProvider getWithSetCallback_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + function testGetWithSetcallback_touched( array $extOpts, $versioned ) { + $cache = $this->cache; + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $checkFunc = function ( $oldVal, &$ttl, array $setOpts, $oldAsOf ) + use ( &$wasSet ) { + ++$wasSet; + + return 'xxx' . $wasSet; + }; + + $key = wfRandomString(); + $wasSet = 0; + $touched = null; + $touchedCallback = function () use ( &$touched ) { + return $touched; + }; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $mockWallClock += 60; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $this->assertEquals( 'xxx1', $v, "Value was computed once" ); + $this->assertEquals( 1, $wasSet, "Value was computed once" ); + + $touched = $mockWallClock - 10; + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $v = $cache->getWithSetCallback( + $key, + $cache::TTL_INDEFINITE, + $checkFunc, + [ 'touchedCallback' => $touchedCallback ] + $extOpts + ); + $this->assertEquals( 'xxx2', $v, "Value was recomputed once" ); + $this->assertEquals( 2, $wasSet, "Value was recomputed once" ); + } + + public static function getWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + public function testPreemtiveRefresh() { + $value = 'KatCafe'; + $wasSet = 0; + $func = function ( $old, &$ttl, &$opts, $asOf ) use ( &$wasSet, &$value ) + { + ++$wasSet; + return $value; + }; + + $cache = new NearExpiringWANObjectCache( [ 'cache' => new HashBagOStuff() ] ); + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 30 ]; + $v = $cache->getWithSetCallback( $key, 20, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 0.2; // interim key is not brand new + $v = $cache->getWithSetCallback( $key, 20, $func, $opts ); + $this->assertEquals( 2, $wasSet, "Value re-calculated" ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 1 ]; + $v = $cache->getWithSetCallback( $key, 30, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + $v = $cache->getWithSetCallback( $key, 30, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Value cached" ); + + $asycList = []; + $asyncHandler = function ( $callback ) use ( &$asycList ) { + $asycList[] = $callback; + }; + $cache = new NearExpiringWANObjectCache( [ + 'cache' => new HashBagOStuff(), + 'asyncHandler' => $asyncHandler + ] ); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'lowTTL' => 100 ]; + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Cached value used" ); + $this->assertEquals( $v, $value, "Value cached" ); + + $mockWallClock += 250; + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Stale value used" ); + $this->assertEquals( 1, count( $asycList ), "Refresh deferred." ); + $value = 'NewCatsInTown'; // change callback return value + $asycList[0](); // run the refresh callback + $asycList = []; + $this->assertEquals( 2, $wasSet, "Value calculated at later time" ); + $this->assertEquals( 0, count( $asycList ), "No deferred refreshes added." ); + $v = $cache->getWithSetCallback( $key, 300, $func, $opts ); + $this->assertEquals( $value, $v, "New value stored" ); + + $cache = new PopularityRefreshingWANObjectCache( [ + 'cache' => new HashBagOStuff() + ] ); + + $mockWallClock = $priorTime; + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'hotTTR' => 900 ]; + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 30; + + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( 1, $wasSet, "Value cached" ); + + $mockWallClock = $priorTime; + $wasSet = 0; + $key = wfRandomString(); + $opts = [ 'hotTTR' => 10 ]; + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value calculated" ); + + $mockWallClock += 30; + + $v = $cache->getWithSetCallback( $key, 60, $func, $opts ); + $this->assertEquals( $value, $v, "Value returned" ); + $this->assertEquals( 2, $wasSet, "Value re-calculated" ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testGetWithSetCallback_invalidCallback() { + $this->setExpectedException( InvalidArgumentException::class ); + $this->cache->getWithSetCallback( 'key', 30, 'invalid callback' ); + } + + /** + * @dataProvider getMultiWithSetCallback_provider + * @covers WANObjectCache::getMultiWithSetCallback + * @covers WANObjectCache::makeMultiKeys + * @covers WANObjectCache::getMulti + * @param array $extOpts + * @param bool $versioned + */ + public function testGetMultiWithSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $keyA = wfRandomString(); + $keyB = wfRandomString(); + $keyC = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $priorValue = null; + $priorAsOf = null; + $wasSet = 0; + $genFunc = function ( $id, $old, &$ttl, &$opts, $asOf ) use ( + &$wasSet, &$priorValue, &$priorAsOf + ) { + ++$wasSet; + $priorValue = $old; + $priorAsOf = $asOf; + $ttl = 20; // override with another value + return "@$id$"; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyA => 3353 ] ); + $value = "@3353$"; + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lockTSE' => 5 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyA], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertFalse( $priorValue, "No prior value" ); + $this->assertNull( $priorAsOf, "No prior value" ); + + $curTTL = null; + $cache->get( $keyA, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $value = "@efef$"; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0, 'lockTSE' => 5, ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + + $mockWallClock += 1; + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $this->assertEquals( $value, $priorValue, "Has prior value" ); + $this->assertInternalType( 'float', $priorAsOf, "Has prior value" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.01; + $priorTime = $mockWallClock; + $value = "@43636$"; + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyC => 43636 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyC], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" ); + $cache->delete( $key ); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $calls = 0; + $ids = [ 1, 2, 3, 4, 5, 6 ]; + $keyFunc = function ( $id, WANObjectCache $wanCache ) { + return $wanCache->makeKey( 'test', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc ); + $genFunc = function ( $id, $oldValue, &$ttl, array &$setops ) use ( &$calls ) { + ++$calls; + + return "val-{$id}"; + }; + $values = $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + + $this->assertEquals( + [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ], + array_values( $values ), + "Correct values in correct order" + ); + $this->assertEquals( + array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ), + array_keys( $values ), + "Correct keys in correct order" + ); + $this->assertEquals( count( $ids ), $calls ); + + $cache->getMultiWithSetCallback( $keyedIds, 10, $genFunc ); + $this->assertEquals( count( $ids ), $calls, "Values cached" ); + + // Mock the BagOStuff to assure only one getMulti() call given process caching + $localBag = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'getMulti' ] )->getMock(); + $localBag->expects( $this->exactly( 1 ) )->method( 'getMulti' )->willReturn( [ + WANObjectCache::VALUE_KEY_PREFIX . 'k1' => 'val-id1', + WANObjectCache::VALUE_KEY_PREFIX . 'k2' => 'val-id2' + ] ); + $wanCache = new WANObjectCache( [ 'cache' => $localBag ] ); + + // Warm the process cache + $keyedIds = new ArrayIterator( [ 'k1' => 'id1', 'k2' => 'id2' ] ); + $this->assertEquals( + [ 'k1' => 'val-id1', 'k2' => 'val-id2' ], + $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] ) + ); + // Use the process cache + $this->assertEquals( + [ 'k1' => 'val-id1', 'k2' => 'val-id2' ], + $wanCache->getMultiWithSetCallback( $keyedIds, 10, $genFunc, [ 'pcTTL' => 5 ] ) + ); + } + + public static function getMultiWithSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @dataProvider getMultiWithUnionSetCallback_provider + * @covers WANObjectCache::getMultiWithUnionSetCallback() + * @covers WANObjectCache::makeMultiKeys() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetMultiWithUnionSetCallback( array $extOpts, $versioned ) { + $cache = $this->cache; + + $keyA = wfRandomString(); + $keyB = wfRandomString(); + $keyC = wfRandomString(); + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $wasSet = 0; + $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use ( + &$wasSet, &$priorValue, &$priorAsOf + ) { + $newValues = []; + foreach ( $ids as $id ) { + ++$wasSet; + $newValues[$id] = "@$id$"; + $ttls[$id] = 20; // override with another value + } + + return $newValues; + }; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyA => 3353 ] ); + $value = "@3353$"; + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, $extOpts ); + $this->assertEquals( $value, $v[$keyA], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + $curTTL = null; + $cache->get( $keyA, $curTTL ); + $this->assertLessThanOrEqual( 20, $curTTL, 'Current TTL between 19-20 (overriden)' ); + $this->assertGreaterThanOrEqual( 19, $curTTL, 'Current TTL between 19-20 (overriden)' ); + + $wasSet = 0; + $value = "@efef$"; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed yet in process cache" ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'lowTTL' => 0 ] + $extOpts ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( 0, $cache->getWarmupKeyMisses(), "Keys warmed in process cache" ); + + $mockWallClock += 1; + + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyB => 'efef' ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyB], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check keys generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check keys generated on miss' ); + + $mockWallClock += 0.01; + $priorTime = $mockWallClock; + $value = "@43636$"; + $wasSet = 0; + $keyedIds = new ArrayIterator( [ $keyC => 43636 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'checkKeys' => [ $cKey1, $cKey2 ] ] + $extOpts + ); + $this->assertEquals( $value, $v[$keyC], "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated due to still-recent check keys" ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertLessThanOrEqual( $priorTime, $t1, 'Check keys did not change again' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertLessThanOrEqual( $priorTime, $t2, 'Check keys did not change again' ); + + $curTTL = null; + $v = $cache->get( $keyC, $curTTL, [ $cKey1, $cKey2 ] ); + if ( $versioned ) { + $this->assertEquals( $value, $v[$cache::VFLD_DATA], "Value returned" ); + } else { + $this->assertEquals( $value, $v, "Value returned" ); + } + $this->assertLessThanOrEqual( 0, $curTTL, "Value has current TTL < 0 due to check keys" ); + + $wasSet = 0; + $key = wfRandomString(); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value returned" ); + $cache->delete( $key ); + $keyedIds = new ArrayIterator( [ $key => 242424 ] ); + $v = $cache->getMultiWithUnionSetCallback( + $keyedIds, 30, $genFunc, [ 'pcTTL' => 5 ] + $extOpts ); + $this->assertEquals( "@{$keyedIds[$key]}$", $v[$key], "Value still returned after deleted" ); + $this->assertEquals( 1, $wasSet, "Value process cached while deleted" ); + + $calls = 0; + $ids = [ 1, 2, 3, 4, 5, 6 ]; + $keyFunc = function ( $id, WANObjectCache $wanCache ) { + return $wanCache->makeKey( 'test', $id ); + }; + $keyedIds = $cache->makeMultiKeys( $ids, $keyFunc ); + $genFunc = function ( array $ids, array &$ttls, array &$setOpts ) use ( &$calls ) { + $newValues = []; + foreach ( $ids as $id ) { + ++$calls; + $newValues[$id] = "val-{$id}"; + } + + return $newValues; + }; + $values = $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc ); + + $this->assertEquals( + [ "val-1", "val-2", "val-3", "val-4", "val-5", "val-6" ], + array_values( $values ), + "Correct values in correct order" + ); + $this->assertEquals( + array_map( $keyFunc, $ids, array_fill( 0, count( $ids ), $this->cache ) ), + array_keys( $values ), + "Correct keys in correct order" + ); + $this->assertEquals( count( $ids ), $calls ); + + $cache->getMultiWithUnionSetCallback( $keyedIds, 10, $genFunc ); + $this->assertEquals( count( $ids ), $calls, "Values cached" ); + } + + public static function getMultiWithUnionSetCallback_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testLockTSE() { + $cache = $this->cache; + $key = wfRandomString(); + $value = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function () use ( &$calls, $value, $cache, $key ) { + ++$calls; + return $value; + }; + + $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Value was populated' ); + + // Acquire the mutex to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); + $this->assertEquals( 1, $calls, 'Callback was not used' ); + + $cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; interim saved' ); + $this->assertEquals( 2, $calls, 'Callback was used; interim saved' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 5, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim (mutex failed)' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used interim (mutex failed)' ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @covers WANObjectCache::set() + */ + public function testLockTSESlow() { + $cache = $this->cache; + $key = wfRandomString(); + $key2 = wfRandomString(); + $value = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value, &$mockWallClock ) { + ++$calls; + $setOpts['since'] = $mockWallClock - 10; + return $value; + }; + + // Value should be given a low logical TTL due to snapshot lag + $curTTL = null; + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( $value, $cache->get( $key, $curTTL ), 'Value was populated' ); + $this->assertEquals( 1, $curTTL, 'Value has reduced logical TTL', 0.01 ); + $this->assertEquals( 1, $calls, 'Value was generated' ); + + $mockWallClock += 2; // low logical TTL expired + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback used (mutex acquired)' ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was not used (interim value used)' ); + + $mockWallClock += 2; // low logical TTL expired + // Acquire a lock to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was not used (mutex not acquired)' ); + + $mockWallClock += 301; // physical TTL expired + // Acquire a lock to verify that getWithSetCallback uses lockTSE properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $ret = $cache->getWithSetCallback( $key, 300, $func, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 3, $calls, 'Callback was used (mutex not acquired, not in cache)' ); + + $calls = 0; + $func2 = function ( $oldValue, &$ttl, &$setOpts ) use ( &$calls, $value ) { + ++$calls; + $setOpts['lag'] = 15; + return $value; + }; + + // Value should be given a low logical TTL due to replication lag + $curTTL = null; + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( $value, $cache->get( $key2, $curTTL ), 'Value was populated' ); + $this->assertEquals( 30, $curTTL, 'Value has reduced logical TTL', 0.01 ); + $this->assertEquals( 1, $calls, 'Value was generated' ); + + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Callback was used (not expired)' ); + + $mockWallClock += 31; + + $ret = $cache->getWithSetCallback( $key2, 300, $func2, [ 'lockTSE' => 5 ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 2, $calls, 'Callback was used (mutex acquired)' ); + } + + /** + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + */ + public function testBusyValue() { + $cache = $this->cache; + $key = wfRandomString(); + $value = wfRandomString(); + $busyValue = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $calls = 0; + $func = function () use ( &$calls, $value, $cache, $key ) { + ++$calls; + return $value; + }; + + $ret = $cache->getWithSetCallback( $key, 30, $func, [ 'busyValue' => $busyValue ] ); + $this->assertEquals( $value, $ret ); + $this->assertEquals( 1, $calls, 'Value was populated' ); + + $mockWallClock += 0.2; // interim keys not brand new + + // Acquire a lock to verify that getWithSetCallback uses busyValue properly + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + + $checkKeys = [ wfRandomString() ]; // new check keys => force misses + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback used' ); + $this->assertEquals( 2, $calls, 'Callback used' ); + + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Old value used' ); + $this->assertEquals( 2, $calls, 'Callback was not used' ); + + $cache->delete( $key ); // no value at all anymore and still locked + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $busyValue, $ret, 'Callback was not used; used busy value' ); + $this->assertEquals( 2, $calls, 'Callback was not used; used busy value' ); + + $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'lockTSE' => 30, 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was used; saved interim' ); + $this->assertEquals( 3, $calls, 'Callback was used; saved interim' ); + + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $ret = $cache->getWithSetCallback( $key, 30, $func, + [ 'busyValue' => $busyValue, 'checkKeys' => $checkKeys ] ); + $this->assertEquals( $value, $ret, 'Callback was not used; used interim' ); + $this->assertEquals( 3, $calls, 'Callback was not used; used interim' ); + } + + /** + * @covers WANObjectCache::getMulti() + */ + public function testGetMulti() { + $cache = $this->cache; + + $value1 = [ 'this' => 'is', 'a' => 'test' ]; + $value2 = [ 'this' => 'is', 'another' => 'test' ]; + + $key1 = wfRandomString(); + $key2 = wfRandomString(); + $key3 = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $cache->set( $key1, $value1, 5 ); + $cache->set( $key2, $value2, 10 ); + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs ), + 'Result array populated' + ); + + $this->assertEquals( 2, count( $curTTLs ), "Two current TTLs in array" ); + $this->assertGreaterThan( 0, $curTTLs[$key1], "Key 1 has current TTL > 0" ); + $this->assertGreaterThan( 0, $curTTLs[$key2], "Key 2 has current TTL > 0" ); + + $cKey1 = wfRandomString(); + $cKey2 = wfRandomString(); + + $mockWallClock += 1; + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ), + "Result array populated even with new check keys" + ); + $t1 = $cache->getCheckKeyTime( $cKey1 ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key 1 generated on miss' ); + $t2 = $cache->getCheckKeyTime( $cKey2 ); + $this->assertGreaterThanOrEqual( $priorTime, $t2, 'Check key 2 generated on miss' ); + $this->assertEquals( 2, count( $curTTLs ), "Current TTLs array set" ); + $this->assertLessThanOrEqual( 0, $curTTLs[$key1], 'Key 1 has current TTL <= 0' ); + $this->assertLessThanOrEqual( 0, $curTTLs[$key2], 'Key 2 has current TTL <= 0' ); + + $mockWallClock += 1; + + $curTTLs = []; + $this->assertEquals( + [ $key1 => $value1, $key2 => $value2 ], + $cache->getMulti( [ $key1, $key2, $key3 ], $curTTLs, [ $cKey1, $cKey2 ] ), + "Result array still populated even with new check keys" + ); + $this->assertEquals( 2, count( $curTTLs ), "Current TTLs still array set" ); + $this->assertLessThan( 0, $curTTLs[$key1], 'Key 1 has negative current TTL' ); + $this->assertLessThan( 0, $curTTLs[$key2], 'Key 2 has negative current TTL' ); + } + + /** + * @covers WANObjectCache::getMulti() + * @covers WANObjectCache::processCheckKeys() + */ + public function testGetMultiCheckKeys() { + $cache = $this->cache; + + $checkAll = wfRandomString(); + $check1 = wfRandomString(); + $check2 = wfRandomString(); + $check3 = wfRandomString(); + $value1 = wfRandomString(); + $value2 = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + // Fake initial check key to be set in the past. Otherwise we'd have to sleep for + // several seconds during the test to assert the behaviour. + foreach ( [ $checkAll, $check1, $check2 ] as $checkKey ) { + $cache->touchCheckKey( $checkKey, WANObjectCache::HOLDOFF_NONE ); + } + + $mockWallClock += 0.100; + + $cache->set( 'key1', $value1, 10 ); + $cache->set( 'key2', $value2, 10 ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'Initial values' + ); + $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key1'], 'Initial ttls' ); + $this->assertLessThanOrEqual( 10.5, $curTTLs['key1'], 'Initial ttls' ); + $this->assertGreaterThanOrEqual( 9.5, $curTTLs['key2'], 'Initial ttls' ); + $this->assertLessThanOrEqual( 10.5, $curTTLs['key2'], 'Initial ttls' ); + + $mockWallClock += 0.100; + $cache->touchCheckKey( $check1 ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'key1 expired by check1, but value still provided' + ); + $this->assertLessThan( 0, $curTTLs['key1'], 'key1 TTL expired' ); + $this->assertGreaterThan( 0, $curTTLs['key2'], 'key2 still valid' ); + + $cache->touchCheckKey( $checkAll ); + + $curTTLs = []; + $result = $cache->getMulti( [ 'key1', 'key2', 'key3' ], $curTTLs, [ + 'key1' => $check1, + $checkAll, + 'key2' => $check2, + 'key3' => $check3, + ] ); + $this->assertEquals( + [ 'key1' => $value1, 'key2' => $value2 ], + $result, + 'All keys expired by checkAll, but value still provided' + ); + $this->assertLessThan( 0, $curTTLs['key1'], 'key1 expired by checkAll' ); + $this->assertLessThan( 0, $curTTLs['key2'], 'key2 expired by checkAll' ); + } + + /** + * @covers WANObjectCache::get() + * @covers WANObjectCache::processCheckKeys() + */ + public function testCheckKeyInitHoldoff() { + $cache = $this->cache; + + for ( $i = 0; $i < 500; ++$i ) { + $key = wfRandomString(); + $checkKey = wfRandomString(); + // miss, set, hit + $cache->get( $key, $curTTL, [ $checkKey ] ); + $cache->set( $key, 'val', 10 ); + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $checkKey ] ); + + $this->assertEquals( 'val', $v ); + $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (miss/set/hit)" ); + } + + for ( $i = 0; $i < 500; ++$i ) { + $key = wfRandomString(); + $checkKey = wfRandomString(); + // set, hit + $cache->set( $key, 'val', 10 ); + $curTTL = null; + $v = $cache->get( $key, $curTTL, [ $checkKey ] ); + + $this->assertEquals( 'val', $v ); + $this->assertLessThan( 0, $curTTL, "Step $i: CTL < 0 (set/hit)" ); + } + } + + /** + * @covers WANObjectCache::delete + * @covers WANObjectCache::relayDelete + * @covers WANObjectCache::relayPurge + */ + public function testDelete() { + $key = wfRandomString(); + $value = wfRandomString(); + $this->cache->set( $key, $value ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertEquals( $value, $v, "Key was created with value" ); + $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" ); + + $this->cache->delete( $key ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key has false value" ); + $this->assertLessThan( 0, $curTTL, "Deleted key has current TTL < 0" ); + + $this->cache->set( $key, $value . 'more' ); + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key is tombstoned and has false value" ); + $this->assertLessThan( 0, $curTTL, "Deleted key is tombstoned and has current TTL < 0" ); + + $this->cache->set( $key, $value ); + $this->cache->delete( $key, WANObjectCache::HOLDOFF_NONE ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL ); + $this->assertFalse( $v, "Deleted key has false value" ); + $this->assertNull( $curTTL, "Deleted key has null current TTL" ); + + $this->cache->set( $key, $value ); + $v = $this->cache->get( $key, $curTTL ); + $this->assertEquals( $value, $v, "Key was created with value" ); + $this->assertGreaterThan( 0, $curTTL, "Existing key has current TTL > 0" ); + } + + /** + * @dataProvider getWithSetCallback_versions_provider + * @covers WANObjectCache::getWithSetCallback() + * @covers WANObjectCache::doGetWithSetCallback() + * @param array $extOpts + * @param bool $versioned + */ + public function testGetWithSetCallback_versions( array $extOpts, $versioned ) { + $cache = $this->cache; + + $key = wfRandomString(); + $valueV1 = wfRandomString(); + $valueV2 = [ wfRandomString() ]; + + $wasSet = 0; + $funcV1 = function () use ( &$wasSet, $valueV1 ) { + ++$wasSet; + + return $valueV1; + }; + + $priorValue = false; + $priorAsOf = null; + $funcV2 = function ( $oldValue, &$ttl, $setOpts, $oldAsOf ) + use ( &$wasSet, $valueV2, &$priorValue, &$priorAsOf ) { + $priorValue = $oldValue; + $priorAsOf = $oldAsOf; + ++$wasSet; + + return $valueV2; // new array format + }; + + // Set the main key (version N if versioned) + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts ); + $this->assertEquals( $valueV1, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $cache->getWithSetCallback( $key, 30, $funcV1, $extOpts ); + $this->assertEquals( 1, $wasSet, "Value not regenerated" ); + $this->assertEquals( $valueV1, $v, "Value not regenerated" ); + + if ( $versioned ) { + // Set the key for version N+1 format + $verOpts = [ 'version' => $extOpts['version'] + 1 ]; + } else { + // Start versioning now with the unversioned key still there + $verOpts = [ 'version' => 1 ]; + } + + // Value goes to secondary key since V1 already used $key + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + $this->assertEquals( false, $priorValue, "Old value not given due to old format" ); + $this->assertEquals( null, $priorAsOf, "Old value not given due to old format" ); + + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value not regenerated (secondary key)" ); + $this->assertEquals( 0, $wasSet, "Value not regenerated (secondary key)" ); + + // Clear out the older or unversioned key + $cache->delete( $key, 0 ); + + // Set the key for next/first versioned format + $wasSet = 0; + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value returned" ); + $this->assertEquals( 1, $wasSet, "Value regenerated" ); + + $v = $cache->getWithSetCallback( $key, 30, $funcV2, $verOpts + $extOpts ); + $this->assertEquals( $valueV2, $v, "Value not regenerated (main key)" ); + $this->assertEquals( 1, $wasSet, "Value not regenerated (main key)" ); + } + + public static function getWithSetCallback_versions_provider() { + return [ + [ [], false ], + [ [ 'version' => 1 ], true ] + ]; + } + + /** + * @covers WANObjectCache::useInterimHoldOffCaching + * @covers WANObjectCache::getInterimValue + */ + public function testInterimHoldOffCaching() { + $cache = $this->cache; + + $mockWallClock = 1549343530.2053; + $cache->setMockTime( $mockWallClock ); + + $value = 'CRL-40-940'; + $wasCalled = 0; + $func = function () use ( &$wasCalled, $value ) { + $wasCalled++; + + return $value; + }; + + $cache->useInterimHoldOffCaching( true ); + + $key = wfRandomString( 32 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 1, $wasCalled, 'Value cached' ); + + $cache->delete( $key ); + $mockWallClock += 0.001; // cached values will be newer than tombstone + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value interim cached' ); // reuses interim + + $mockWallClock += 0.2; // interim key not brand new + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value regenerated (got mutex)' ); // sets interim + // Lock up the mutex so interim cache is used + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value interim cached (failed mutex)' ); + $this->internalCache->delete( $cache::MUTEX_KEY_PREFIX . $key ); + + $cache->useInterimHoldOffCaching( false ); + + $wasCalled = 0; + $key = wfRandomString( 32 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 1, $wasCalled, 'Value cached' ); + $cache->delete( $key ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 2, $wasCalled, 'Value regenerated (got mutex)' ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 3, $wasCalled, 'Value still regenerated (got mutex)' ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 4, $wasCalled, 'Value still regenerated (got mutex)' ); + // Lock up the mutex so interim cache is used + $this->internalCache->add( $cache::MUTEX_KEY_PREFIX . $key, 1, 0 ); + $v = $cache->getWithSetCallback( $key, 60, $func ); + $this->assertEquals( 5, $wasCalled, 'Value still regenerated (failed mutex)' ); + } + + /** + * @covers WANObjectCache::touchCheckKey + * @covers WANObjectCache::resetCheckKey + * @covers WANObjectCache::getCheckKeyTime + * @covers WANObjectCache::getMultiCheckKeyTime + * @covers WANObjectCache::makePurgeValue + * @covers WANObjectCache::parsePurgeValue + */ + public function testTouchKeys() { + $cache = $this->cache; + $key = wfRandomString(); + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $cache->setMockTime( $mockWallClock ); + + $mockWallClock += 0.100; + $t0 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThanOrEqual( $priorTime, $t0, 'Check key auto-created' ); + + $priorTime = $mockWallClock; + $mockWallClock += 0.100; + $cache->touchCheckKey( $key ); + $t1 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThanOrEqual( $priorTime, $t1, 'Check key created' ); + + $t2 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t1, $t2, 'Check key time did not change' ); + + $mockWallClock += 0.100; + $cache->touchCheckKey( $key ); + $t3 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThan( $t2, $t3, 'Check key time increased' ); + + $t4 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t3, $t4, 'Check key time did not change' ); + + $mockWallClock += 0.100; + $cache->resetCheckKey( $key ); + $t5 = $cache->getCheckKeyTime( $key ); + $this->assertGreaterThan( $t4, $t5, 'Check key time increased' ); + + $t6 = $cache->getCheckKeyTime( $key ); + $this->assertEquals( $t5, $t6, 'Check key time did not change' ); + } + + /** + * @covers WANObjectCache::getMulti() + */ + public function testGetWithSeveralCheckKeys() { + $key = wfRandomString(); + $tKey1 = wfRandomString(); + $tKey2 = wfRandomString(); + $value = 'meow'; + + $mockWallClock = 1549343530.2053; + $priorTime = $mockWallClock; // reference time + $this->cache->setMockTime( $mockWallClock ); + + // Two check keys are newer (given hold-off) than $key, another is older + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 3 ) + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 5 ) + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey1, + WANObjectCache::PURGE_VAL_PREFIX . ( $priorTime - 30 ) + ); + $this->cache->set( $key, $value, 30 ); + + $curTTL = null; + $v = $this->cache->get( $key, $curTTL, [ $tKey1, $tKey2 ] ); + $this->assertEquals( $value, $v, "Value matches" ); + $this->assertLessThan( -4.9, $curTTL, "Correct CTL" ); + $this->assertGreaterThan( -5.1, $curTTL, "Correct CTL" ); + } + + /** + * @covers WANObjectCache::reap() + * @covers WANObjectCache::reapCheckKey() + */ + public function testReap() { + $vKey1 = wfRandomString(); + $vKey2 = wfRandomString(); + $tKey1 = wfRandomString(); + $tKey2 = wfRandomString(); + $value = 'moo'; + + $knownPurge = time() - 60; + $goodTime = microtime( true ) - 5; + $badTime = microtime( true ) - 300; + + $this->internalCache->set( + WANObjectCache::VALUE_KEY_PREFIX . $vKey1, + [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => $value, + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => $goodTime + ] + ); + $this->internalCache->set( + WANObjectCache::VALUE_KEY_PREFIX . $vKey2, + [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => $value, + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => $badTime + ] + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey1, + WANObjectCache::PURGE_VAL_PREFIX . $goodTime + ); + $this->internalCache->set( + WANObjectCache::TIME_KEY_PREFIX . $tKey2, + WANObjectCache::PURGE_VAL_PREFIX . $badTime + ); + + $this->assertEquals( $value, $this->cache->get( $vKey1 ) ); + $this->assertEquals( $value, $this->cache->get( $vKey2 ) ); + $this->cache->reap( $vKey1, $knownPurge, $bad1 ); + $this->cache->reap( $vKey2, $knownPurge, $bad2 ); + + $this->assertFalse( $bad1 ); + $this->assertTrue( $bad2 ); + + $this->cache->reapCheckKey( $tKey1, $knownPurge, $tBad1 ); + $this->cache->reapCheckKey( $tKey2, $knownPurge, $tBad2 ); + $this->assertFalse( $tBad1 ); + $this->assertTrue( $tBad2 ); + } + + /** + * @covers WANObjectCache::reap() + */ + public function testReap_fail() { + $backend = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'get', 'changeTTL' ] )->getMock(); + $backend->expects( $this->once() )->method( 'get' ) + ->willReturn( [ + WANObjectCache::FLD_VERSION => WANObjectCache::VERSION, + WANObjectCache::FLD_VALUE => 'value', + WANObjectCache::FLD_TTL => 3600, + WANObjectCache::FLD_TIME => 300, + ] ); + $backend->expects( $this->once() )->method( 'changeTTL' ) + ->willReturn( false ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $isStale = null; + $ret = $wanCache->reap( 'key', 360, $isStale ); + $this->assertTrue( $isStale, 'value was stale' ); + $this->assertFalse( $ret, 'changeTTL failed' ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testSetWithLag() { + $value = 1; + + $key = wfRandomString(); + $opts = [ 'lag' => 300, 'since' => microtime( true ) ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( $value, $this->cache->get( $key ), "Rep-lagged value written." ); + + $key = wfRandomString(); + $opts = [ 'lag' => 0, 'since' => microtime( true ) - 300 ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Trx-lagged value not written." ); + + $key = wfRandomString(); + $opts = [ 'lag' => 5, 'since' => microtime( true ) - 5 ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Lagged value not written." ); + } + + /** + * @covers WANObjectCache::set() + */ + public function testWritePending() { + $value = 1; + + $key = wfRandomString(); + $opts = [ 'pending' => true ]; + $this->cache->set( $key, $value, 30, $opts ); + $this->assertEquals( false, $this->cache->get( $key ), "Pending value not written." ); + } + + public function testMcRouterSupport() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set', 'delete' ] )->getMock(); + $localBag->expects( $this->never() )->method( 'set' ); + $localBag->expects( $this->never() )->method( 'delete' ); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + $valFunc = function () { + return 1; + }; + + // None of these should use broadcasting commands (e.g. SET, DELETE) + $wanCache->get( 'x' ); + $wanCache->get( 'x', $ctl, [ 'check1' ] ); + $wanCache->getMulti( [ 'x', 'y' ] ); + $wanCache->getMulti( [ 'x', 'y' ], $ctls, [ 'check2' ] ); + $wanCache->getWithSetCallback( 'p', 30, $valFunc ); + $wanCache->getCheckKeyTime( 'zzz' ); + $wanCache->reap( 'x', time() - 300 ); + $wanCache->reap( 'zzz', time() - 300 ); + } + + public function testMcRouterSupportBroadcastDelete() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'set' ) + ->with( "/*/mw-wan/" . $wanCache::VALUE_KEY_PREFIX . "test" ); + + $wanCache->delete( 'test' ); + } + + public function testMcRouterSupportBroadcastTouchCK() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'set' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'set' ) + ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + + $wanCache->touchCheckKey( 'test' ); + } + + public function testMcRouterSupportBroadcastResetCK() { + $localBag = $this->getMockBuilder( EmptyBagOStuff::class ) + ->setMethods( [ 'delete' ] )->getMock(); + $wanCache = new WANObjectCache( [ + 'cache' => $localBag, + 'mcrouterAware' => true, + 'region' => 'pmtpa', + 'cluster' => 'mw-wan' + ] ); + + $localBag->expects( $this->once() )->method( 'delete' ) + ->with( "/*/mw-wan/" . $wanCache::TIME_KEY_PREFIX . "test" ); + + $wanCache->resetCheckKey( 'test' ); + } + + public function testEpoch() { + $bag = new HashBagOStuff(); + $cache = new WANObjectCache( [ 'cache' => $bag ] ); + $key = $cache->makeGlobalKey( 'The whole of the Law' ); + + $now = microtime( true ); + $cache->setMockTime( $now ); + + $cache->set( $key, 'Do what thou Wilt' ); + $cache->touchCheckKey( $key ); + + $then = $now; + $now += 30; + $this->assertEquals( 'Do what thou Wilt', $cache->get( $key ) ); + $this->assertEquals( $then, $cache->getCheckKeyTime( $key ), 'Check key init', 0.01 ); + + $cache = new WANObjectCache( [ + 'cache' => $bag, + 'epoch' => $now - 3600 + ] ); + $cache->setMockTime( $now ); + + $this->assertEquals( 'Do what thou Wilt', $cache->get( $key ) ); + $this->assertEquals( $then, $cache->getCheckKeyTime( $key ), 'Check key kept', 0.01 ); + + $now += 30; + $cache = new WANObjectCache( [ + 'cache' => $bag, + 'epoch' => $now + 3600 + ] ); + $cache->setMockTime( $now ); + + $this->assertFalse( $cache->get( $key ), 'Key rejected due to epoch' ); + $this->assertEquals( $now, $cache->getCheckKeyTime( $key ), 'Check key reset', 0.01 ); + } + + /** + * @dataProvider provideAdaptiveTTL + * @covers WANObjectCache::adaptiveTTL() + * @param float|int $ago + * @param int $maxTTL + * @param int $minTTL + * @param float $factor + * @param int $adaptiveTTL + */ + public function testAdaptiveTTL( $ago, $maxTTL, $minTTL, $factor, $adaptiveTTL ) { + $mtime = $ago ? time() - $ago : $ago; + $margin = 5; + $ttl = $this->cache->adaptiveTTL( $mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + + $ttl = $this->cache->adaptiveTTL( (string)$mtime, $maxTTL, $minTTL, $factor ); + + $this->assertGreaterThanOrEqual( $adaptiveTTL - $margin, $ttl ); + $this->assertLessThanOrEqual( $adaptiveTTL + $margin, $ttl ); + } + + public static function provideAdaptiveTTL() { + return [ + [ 3600, 900, 30, 0.2, 720 ], + [ 3600, 500, 30, 0.2, 500 ], + [ 3600, 86400, 800, 0.2, 800 ], + [ false, 86400, 800, 0.2, 800 ], + [ null, 86400, 800, 0.2, 800 ] + ]; + } + + /** + * @covers WANObjectCache::__construct + * @covers WANObjectCache::newEmpty + */ + public function testNewEmpty() { + $this->assertInstanceOf( + WANObjectCache::class, + WANObjectCache::newEmpty() + ); + } + + /** + * @covers WANObjectCache::setLogger + */ + public function testSetLogger() { + $this->assertSame( null, $this->cache->setLogger( new Psr\Log\NullLogger ) ); + } + + /** + * @covers WANObjectCache::getQoS + */ + public function testGetQoS() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'getQoS' ] )->getMock(); + $backend->expects( $this->once() )->method( 'getQoS' ) + ->willReturn( BagOStuff::QOS_UNKNOWN ); + $wanCache = new WANObjectCache( [ 'cache' => $backend ] ); + + $this->assertSame( + $wanCache::QOS_UNKNOWN, + $wanCache->getQoS( $wanCache::ATTR_EMULATION ) + ); + } + + /** + * @covers WANObjectCache::makeKey + */ + public function testMakeKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeKey' ] )->getMock(); + $backend->expects( $this->once() )->method( 'makeKey' ) + ->willReturn( 'special' ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $this->assertSame( 'special', $wanCache->makeKey( 'a', 'b' ) ); + } + + /** + * @covers WANObjectCache::makeGlobalKey + */ + public function testMakeGlobalKey() { + $backend = $this->getMockBuilder( HashBagOStuff::class ) + ->setMethods( [ 'makeGlobalKey' ] )->getMock(); + $backend->expects( $this->once() )->method( 'makeGlobalKey' ) + ->willReturn( 'special' ); + + $wanCache = new WANObjectCache( [ + 'cache' => $backend + ] ); + + $this->assertSame( 'special', $wanCache->makeGlobalKey( 'a', 'b' ) ); + } + + public static function statsKeyProvider() { + return [ + [ 'domain:page:5', 'page' ], + [ 'domain:main-key', 'main-key' ], + [ 'domain:page:history', 'page' ], + [ 'missingdomainkey', 'missingdomainkey' ] + ]; + } + + /** + * @dataProvider statsKeyProvider + * @covers WANObjectCache::determineKeyClassForStats + */ + public function testStatsKeyClass( $key, $class ) { + $wanCache = TestingAccessWrapper::newFromObject( new WANObjectCache( [ + 'cache' => new HashBagOStuff + ] ) ); + + $this->assertEquals( $class, $wanCache->determineKeyClassForStats( $key ) ); + } +} + +class NearExpiringWANObjectCache extends WANObjectCache { + const CLOCK_SKEW = 1; + + protected function worthRefreshExpiring( $curTTL, $lowTTL ) { + return ( $curTTL > 0 && ( $curTTL + self::CLOCK_SKEW ) < $lowTTL ); + } +} + +class PopularityRefreshingWANObjectCache extends WANObjectCache { + protected function worthRefreshPopular( $asOf, $ageNew, $timeTillRefresh, $now ) { + return ( ( $now - $asOf ) > $timeTillRefresh ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php new file mode 100644 index 0000000000..5901bc108c --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/ChronologyProtectorTest.php @@ -0,0 +1,81 @@ +assertEquals( $expectedId, $cp->getClientId() ); + } + + public function clientIdProvider() { + return [ + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + '', + '45e93a9c215c031d38b7c42d8e4700ca', + ], + [ + [ + 'ip' => '127.0.0.7', + 'agent' => "Totally-Not-FireFox" + ], + '', + 'b1d604117b51746c35c3df9f293c84dc' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-FireFox" + ], + '', + '731b4e06a65e2346b497fc811571c4d7' + ], + [ + [ + 'ip' => '127.0.0.1', + 'agent' => "Totally-Not-FireFox" + ], + 'secret', + 'defff51ded73cd901253d874c9b2077d' + ] + ]; + } +} diff --git a/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php b/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php new file mode 100644 index 0000000000..538d625cc2 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/TransactionProfilerTest.php @@ -0,0 +1,147 @@ +getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 3 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'maxAffected', 100, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 3, true, 200 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 3, true, 200 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 400 ); + } + + public function testReadTime() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per query + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'readQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, false, 1 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, false, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 0, 0 ); + } + + public function testWriteTime() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per query, 1 per trx, and one "sub-optimal trx" entry + $logger->expects( $this->exactly( 4 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 10, true, 1 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 10, true, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 20, 1 ); + } + + public function testAffectedTrx() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 1 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'maxAffected', 100, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 200 ); + } + + public function testWriteTimeTrx() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + // 1 per trx, and one "sub-optimal trx" entry + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writeQueryTime', 5, __METHOD__ ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 10, 1 ); + } + + public function testConns() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'conns', 2, __METHOD__ ); + + $tp->recordConnection( 'srv1', 'db1', false ); + $tp->recordConnection( 'srv1', 'db2', false ); + $tp->recordConnection( 'srv1', 'db3', false ); // warn + $tp->recordConnection( 'srv1', 'db4', false ); // warn + } + + public function testMasterConns() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'masterConns', 2, __METHOD__ ); + + $tp->recordConnection( 'srv1', 'db1', false ); + $tp->recordConnection( 'srv1', 'db2', false ); + + $tp->recordConnection( 'srv1', 'db1', true ); + $tp->recordConnection( 'srv1', 'db2', true ); + $tp->recordConnection( 'srv1', 'db3', true ); // warn + $tp->recordConnection( 'srv1', 'db4', true ); // warn + } + + public function testReadQueryCount() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'queries', 2, __METHOD__ ); + + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 ); // warn + $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 ); // warn + } + + public function testWriteQueryCount() { + $logger = $this->getMockBuilder( LoggerInterface::class )->getMock(); + $logger->expects( $this->exactly( 2 ) )->method( 'warning' ); + + $tp = new TransactionProfiler(); + $tp->setLogger( $logger ); + $tp->setExpectation( 'writes', 2, __METHOD__ ); + + $tp->recordQueryCompletion( "SQL 1", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 2", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 3", microtime( true ) - 0.01, false, 0 ); + $tp->recordQueryCompletion( "SQL 4", microtime( true ) - 0.01, false, 0 ); + + $tp->transactionWritingIn( 'srv1', 'db1', '123' ); + $tp->recordQueryCompletion( "SQL 1w", microtime( true ) - 0.01, true, 2 ); + $tp->recordQueryCompletion( "SQL 2w", microtime( true ) - 0.01, true, 5 ); + $tp->recordQueryCompletion( "SQL 3w", microtime( true ) - 0.01, true, 3 ); + $tp->recordQueryCompletion( "SQL 4w", microtime( true ) - 0.01, true, 1 ); + $tp->transactionWritingOut( 'srv1', 'db1', '123', 1, 1 ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php new file mode 100644 index 0000000000..dd86a73eca --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/ConnectionManagerTest.php @@ -0,0 +1,139 @@ +getMockBuilder( IDatabase::class ) + ->getMock(); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnection_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnection( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new ConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } + + public function testGetReadConnectionRef_nullGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionRef_withGroups() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_REPLICA, [ 'group2' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getReadConnectionRef( [ 'group2' ] ); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnectionRef() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnectionRef' ) + ->with( DB_MASTER, [ 'group1' ], 'someDbName' ) + ->will( $this->returnValue( $database ) ); + + $manager = new ConnectionManager( $lb, 'someDbName', [ 'group1' ] ); + $actual = $manager->getWriteConnectionRef(); + + $this->assertSame( $database, $actual ); + } + +} diff --git a/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php new file mode 100644 index 0000000000..8d7d104c1e --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManagerTest.php @@ -0,0 +1,108 @@ +getMockBuilder( IDatabase::class ) + ->getMock(); + } + + /** + * @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject + */ + private function getLoadBalancerMock() { + $lb = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->getMock(); + + return $lb; + } + + public function testGetReadConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_REPLICA ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetReadConnectionReturnsWriteDbOnForceMatser() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $actual = $manager->getReadConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testGetWriteConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $actual = $manager->getWriteConnection(); + + $this->assertSame( $database, $actual ); + } + + public function testForceMaster() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER ) + ->will( $this->returnValue( $database ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->prepareForUpdates(); + $manager->getReadConnection(); + } + + public function testReleaseConnection() { + $database = $this->getIDatabaseMock(); + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ) + ->with( $database ) + ->will( $this->returnValue( null ) ); + + $manager = new SessionConsistentConnectionManager( $lb ); + $manager->releaseConnection( $database ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php new file mode 100644 index 0000000000..33e5c3b3fb --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php @@ -0,0 +1,223 @@ +getMock( ILoadBalancer::class ); + + $lb->method( 'getConnection' )->willReturnCallback( + function () { + return $this->getDatabaseMock(); + } + ); + + $lb->method( 'getConnectionRef' )->willReturnCallback( + function () use ( $lb ) { + return $this->getDBConnRef( $lb ); + } + ); + + return $lb; + } + + /** + * @return IDatabase + */ + private function getDatabaseMock() { + $db = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->getMock(); + + $open = true; + $db->method( 'select' )->willReturnCallback( function () use ( &$open ) { + if ( !$open ) { + throw new LogicException( "Not open" ); + } + + return new FakeResultWrapper( [] ); + } ); + $db->method( 'close' )->willReturnCallback( function () use ( &$open ) { + $open = false; + + return true; + } ); + $db->method( 'isOpen' )->willReturnCallback( function () use ( &$open ) { + return $open; + } ); + $db->method( 'open' )->willReturnCallback( function () use ( &$open ) { + $open = true; + + return $open; + } ); + $db->method( '__toString' )->willReturn( 'MOCK_DB' ); + + return $db; + } + + /** + * @return IDatabase + */ + private function getDBConnRef( ILoadBalancer $lb = null ) { + $lb = $lb ?: $this->getLoadBalancerMock(); + return new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); + } + + public function testConstruct() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testConstruct_params() { + $lb = $this->getMock( ILoadBalancer::class ); + + $lb->expects( $this->once() ) + ->method( 'getConnection' ) + ->with( DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ) + ->willReturnCallback( + function () { + return $this->getDatabaseMock(); + } + ); + + $ref = new DBConnRef( + $lb, + [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ], + DB_MASTER + ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + $this->assertEquals( DB_MASTER, $ref->getReferenceRole() ); + + $ref2 = new DBConnRef( + $lb, + [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ], + DB_REPLICA + ); + $this->assertEquals( DB_REPLICA, $ref2->getReferenceRole() ); + } + + public function testDestruct() { + $lb = $this->getLoadBalancerMock(); + + $lb->expects( $this->once() ) + ->method( 'reuseConnection' ); + + $this->innerMethodForTestDestruct( $lb ); + } + + private function innerMethodForTestDestruct( ILoadBalancer $lb ) { + $ref = $lb->getConnectionRef( DB_REPLICA ); + + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testConstruct_failure() { + $this->setExpectedException( InvalidArgumentException::class, '' ); + + $lb = $this->getLoadBalancerMock(); + new DBConnRef( $lb, 17, DB_REPLICA ); // bad constructor argument + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getDomainId + */ + public function testGetDomainID() { + $lb = $this->getMock( ILoadBalancer::class ); + + // getDomainID is optimized to not create a connection + $lb->expects( $this->never() ) + ->method( 'getConnection' ); + + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + + $this->assertSame( 'dummy', $ref->getDomainID() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::select + */ + public function testSelect() { + // select should get passed through normally + $ref = $this->getDBConnRef(); + $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) ); + } + + public function testToString() { + $ref = $this->getDBConnRef(); + $this->assertInternalType( 'string', $ref->__toString() ); + + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'test', 0 ], DB_MASTER ); + $this->assertInternalType( 'string', $ref->__toString() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::close + * @expectedException \Wikimedia\Rdbms\DBUnexpectedError + */ + public function testClose() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_MASTER ); + $ref->close(); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole + */ + public function testGetReferenceRole() { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + $this->assertSame( DB_REPLICA, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'dummy', 0 ], DB_MASTER ); + $this->assertSame( DB_MASTER, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ 1, [], 'dummy', 0 ], DB_REPLICA ); + $this->assertSame( DB_REPLICA, $ref->getReferenceRole() ); + + $ref = new DBConnRef( $lb, [ 0, [], 'dummy', 0 ], DB_MASTER ); + $this->assertSame( DB_MASTER, $ref->getReferenceRole() ); + } + + /** + * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole + * @expectedException Wikimedia\Rdbms\DBReadOnlyRoleError + * @dataProvider provideRoleExceptions + */ + public function testRoleExceptions( $method, $args ) { + $lb = $this->getLoadBalancerMock(); + $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA ); + $ref->$method( ...$args ); + } + + function provideRoleExceptions() { + return [ + [ 'insert', [ 'table', [ 'a' => 1 ] ] ], + [ 'update', [ 'table', [ 'a' => 1 ], [ 'a' => 2 ] ] ], + [ 'delete', [ 'table', [ 'a' => 1 ] ] ], + [ 'replace', [ 'table', [ 'a' ], [ 'a' => 1 ] ] ], + [ 'upsert', [ 'table', [ 'a' => 1 ], [ 'a' ], [ 'a = a + 1' ] ] ], + [ 'lock', [ 'k', 'method' ] ], + [ 'unlock', [ 'k', 'method' ] ], + [ 'getScopedLockAndFlush', [ 'k', 'method', 1 ] ] + ]; + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php new file mode 100644 index 0000000000..b1d4fadb7d --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseDomainTest.php @@ -0,0 +1,226 @@ + + [ 'foo', 'bar', 'baz_', 'foo-bar-baz_' ], + 'Nothing' => + [ null, null, '', '' ], + 'Invalid $database' => + [ 0, 'bar', '', '', true ], + 'Invalid $schema' => + [ 'foo', 0, '', '', true ], + 'Invalid $prefix' => + [ 'foo', 'bar', 0, '', true ], + 'Dash' => + [ 'foo-bar', 'baz', 'baa_', 'foo?hbar-baz-baa_' ], + 'Question mark' => + [ 'foo?bar', 'baz', 'baa_', 'foo??bar-baz-baa_' ], + ]; + } + + /** + * @dataProvider provideConstruct + */ + public function testConstruct( $db, $schema, $prefix, $id, $exception = false ) { + if ( $exception ) { + $this->setExpectedException( InvalidArgumentException::class ); + new DatabaseDomain( $db, $schema, $prefix ); + return; + } + + $domain = new DatabaseDomain( $db, $schema, $prefix ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + $this->assertEquals( $id, $domain->getId() ); + $this->assertEquals( $id, strval( $domain ), 'toString' ); + } + + public static function provideNewFromId() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '' ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_' ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + '? is left alone' => + [ 'foo?bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + 'too many parts' => + [ 'foo-bar-baz-baa_', '', '', '', true ], + 'from instance' => + [ DatabaseDomain::newUnspecified(), null, null, '' ], + ]; + } + + /** + * @dataProvider provideNewFromId + */ + public function testNewFromId( $id, $db, $schema, $prefix, $exception = false ) { + if ( $exception ) { + $this->setExpectedException( InvalidArgumentException::class ); + DatabaseDomain::newFromId( $id ); + return; + } + $domain = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertEquals( $db, $domain->getDatabase() ); + $this->assertEquals( $schema, $domain->getSchema() ); + $this->assertEquals( $prefix, $domain->getTablePrefix() ); + } + + public static function provideEquals() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '' ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_' ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ], + 'Nothing' => + [ '', null, null, '' ], + ]; + } + + /** + * @dataProvider provideEquals + * @covers Wikimedia\Rdbms\DatabaseDomain::equals + */ + public function testEquals( $id, $db, $schema, $prefix ) { + $fromId = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $fromId ); + + $constructed = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertTrue( $constructed->equals( $id ), 'constructed equals string' ); + $this->assertTrue( $fromId->equals( $id ), 'fromId equals string' ); + + $this->assertTrue( $constructed->equals( $fromId ), 'compare constructed to newId' ); + $this->assertTrue( $fromId->equals( $constructed ), 'compare newId to constructed' ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseDomain::newUnspecified + */ + public function testNewUnspecified() { + $domain = DatabaseDomain::newUnspecified(); + $this->assertInstanceOf( DatabaseDomain::class, $domain ); + $this->assertTrue( $domain->equals( '' ) ); + $this->assertSame( null, $domain->getDatabase() ); + $this->assertSame( null, $domain->getSchema() ); + $this->assertSame( '', $domain->getTablePrefix() ); + } + + public static function provideIsCompatible() { + return [ + 'Basic' => + [ 'foo', 'foo', null, '', true ], + 'db+prefix' => + [ 'foo-bar_', 'foo', null, 'bar_', true ], + 'db+schema+prefix' => + [ 'foo-bar-baz_', 'foo', 'bar', 'baz_', true ], + 'db+dontcare_schema+prefix' => + [ 'foo-bar-baz_', 'foo', null, 'baz_', false ], + '?h -> -' => + [ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_', true ], + '?? -> ?' => + [ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_', true ], + 'Nothing' => + [ '', null, null, '', true ], + 'dontcaredb+dontcaredbschema+prefix' => + [ 'mywiki-mediawiki-prefix_', null, null, 'prefix_', false ], + 'db+dontcareschema+prefix' => + [ 'mywiki-schema-prefix_', 'mywiki', null, 'prefix_', false ], + 'postgres-db-jobqueue' => + [ 'postgres-mediawiki-', 'postgres', null, '', false ] + ]; + } + + /** + * @dataProvider provideIsCompatible + * @covers Wikimedia\Rdbms\DatabaseDomain::isCompatible + */ + public function testIsCompatible( $id, $db, $schema, $prefix, $transitive ) { + $compareIdObj = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $compareIdObj ); + + $fromId = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertTrue( $fromId->isCompatible( $id ), 'constructed equals string' ); + $this->assertTrue( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' ); + + $this->assertEquals( $transitive, $compareIdObj->isCompatible( $fromId ), + 'test transitivity of nulls components' ); + } + + public static function provideIsCompatible2() { + return [ + 'db+schema+prefix' => + [ 'mywiki-schema-prefix_', 'thatwiki', 'schema', 'prefix_' ], + 'dontcaredb+dontcaredbschema+prefix' => + [ 'thatwiki-mediawiki-otherprefix_', null, null, 'prefix_' ], + 'db+dontcareschema+prefix' => + [ 'notmywiki-schema-prefix_', 'mywiki', null, 'prefix_' ], + ]; + } + + /** + * @dataProvider provideIsCompatible2 + * @covers Wikimedia\Rdbms\DatabaseDomain::isCompatible + */ + public function testIsCompatible2( $id, $db, $schema, $prefix ) { + $compareIdObj = DatabaseDomain::newFromId( $id ); + $this->assertInstanceOf( DatabaseDomain::class, $compareIdObj ); + + $fromId = new DatabaseDomain( $db, $schema, $prefix ); + + $this->assertFalse( $fromId->isCompatible( $id ), 'constructed equals string' ); + $this->assertFalse( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSchemaWithNoDB1() { + new DatabaseDomain( null, 'schema', '' ); + } + + /** + * @expectedException InvalidArgumentException + */ + public function testSchemaWithNoDB2() { + DatabaseDomain::newFromId( '-schema-prefix' ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseDomain::isUnspecified + */ + public function testIsUnspecified() { + $domain = new DatabaseDomain( null, null, '' ); + $this->assertTrue( $domain->isUnspecified() ); + $domain = new DatabaseDomain( 'mywiki', null, '' ); + $this->assertFalse( $domain->isUnspecified() ); + $domain = new DatabaseDomain( 'mywiki', null, '' ); + $this->assertFalse( $domain->isUnspecified() ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php new file mode 100644 index 0000000000..414042ddcf --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMssqlTest.php @@ -0,0 +1,62 @@ +getMockBuilder( DatabaseMssql::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTRING(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTRING(someField,1,2147483647)' ]; + yield [ 'someField', 1, 3333333333, 'SUBSTRING(someField,1,3333333333)' ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $mockDb = $this->getMockDb(); + $output = $mockDb->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMssql::buildSubstring + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $mockDb = $this->getMockDb(); + $this->setExpectedException( InvalidArgumentException::class ); + $mockDb->buildSubstring( 'foo', $start, $length ); + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseMssql::getAttributes + */ + public function testAttributes() { + $this->assertTrue( DatabaseMssql::getAttributes()[Database::ATTR_SCHEMAS_AS_TABLE_GROUPS] ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php new file mode 100644 index 0000000000..4c92545128 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseMysqlBaseTest.php @@ -0,0 +1,740 @@ +getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $quoted = $db->addIdentifierQuotes( $in ); + $this->assertEquals( $expected, $quoted ); + } + + /** + * Feeds testAddIdentifierQuotes + * + * Named per T22281 convention. + */ + public static function provideDiapers() { + return [ + // Format: expected, input + [ '``', '' ], + + // Yeah I really hate loosely typed PHP idiocies nowadays + [ '``', null ], + + // Dear codereviewer, guess what addIdentifierQuotes() + // will return with thoses: + [ '``', false ], + [ '`1`', true ], + + // We never know what could happen + [ '`0`', 0 ], + [ '`1`', 1 ], + + // Whatchout! Should probably use something more meaningful + [ "`'`", "'" ], # single quote + [ '`"`', '"' ], # double quote + [ '````', '`' ], # backtick + [ '`’`', '’' ], # apostrophe (look at your encyclopedia) + + // sneaky NUL bytes are lurking everywhere + [ '``', "\0" ], + [ '`xyzzy`', "\0x\0y\0z\0z\0y\0" ], + + // unicode chars + [ + "`\u{0001}a\u{FFFF}b`", + "\u{0001}a\u{FFFF}b" + ], + [ + "`\u{0001}\u{FFFF}`", + "\u{0001}\u{0000}\u{FFFF}\u{0000}" + ], + [ '`☃`', '☃' ], + [ '`メインページ`', 'メインページ' ], + [ '`Басты_бет`', 'Басты_бет' ], + + // Real world: + [ '`Alix`', 'Alix' ], # while( ! $recovered ) { sleep(); } + [ '`Backtick: ```', 'Backtick: `' ], + [ '`This is a test`', 'This is a test' ], + ]; + } + + private function getMockForViews() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'fetchRow', 'query', 'getDBname' ] ) + ->getMock(); + + $db->method( 'query' ) + ->with( $this->anything() ) + ->willReturn( new FakeResultWrapper( [ + (object)[ 'Tables_in_' => 'view1' ], + (object)[ 'Tables_in_' => 'view2' ], + (object)[ 'Tables_in_' => 'myview' ] + ] ) ); + $db->method( 'getDBname' )->willReturn( '' ); + + return $db; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::listViews + */ + public function testListviews() { + $db = $this->getMockForViews(); + + $this->assertEquals( [ 'view1', 'view2', 'myview' ], + $db->listViews() ); + + // Prefix filtering + $this->assertEquals( [ 'view1', 'view2' ], + $db->listViews( 'view' ) ); + $this->assertEquals( [ 'myview' ], + $db->listViews( 'my' ) ); + $this->assertEquals( [], + $db->listViews( 'UNUSED_PREFIX' ) ); + $this->assertEquals( [ 'view1', 'view2', 'myview' ], + $db->listViews( '' ) ); + } + + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testBinLogName() { + $pos = new MySQLMasterPos( "db1052.2424/4643", 1 ); + + $this->assertEquals( "db1052", $pos->getLogName() ); + $this->assertEquals( "db1052.2424", $pos->getLogFile() ); + $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() ); + } + + /** + * @dataProvider provideComparePositions + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testHasReached( + MySQLMasterPos $lowerPos, MySQLMasterPos $higherPos, $match, $hetero + ) { + if ( $match ) { + $this->assertTrue( $lowerPos->channelsMatch( $higherPos ) ); + + if ( $hetero ) { + // Each position is has one channel higher than the other + $this->assertFalse( $higherPos->hasReached( $lowerPos ) ); + } else { + $this->assertTrue( $higherPos->hasReached( $lowerPos ) ); + } + $this->assertTrue( $lowerPos->hasReached( $lowerPos ) ); + $this->assertTrue( $higherPos->hasReached( $higherPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } else { // channels don't match + $this->assertFalse( $lowerPos->channelsMatch( $higherPos ) ); + + $this->assertFalse( $higherPos->hasReached( $lowerPos ) ); + $this->assertFalse( $lowerPos->hasReached( $higherPos ) ); + } + } + + public static function provideComparePositions() { + $now = microtime( true ); + + return [ + // Binlog style + [ + new MySQLMasterPos( 'db1034-bin.000976/843431247', $now ), + new MySQLMasterPos( 'db1034-bin.000976/843431248', $now ), + true, + false + ], + [ + new MySQLMasterPos( 'db1034-bin.000976/999', $now ), + new MySQLMasterPos( 'db1034-bin.000976/1000', $now ), + true, + false + ], + [ + new MySQLMasterPos( 'db1034-bin.000976/999', $now ), + new MySQLMasterPos( 'db1035-bin.000976/1000', $now ), + false, + false + ], + // MySQL GTID style + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ), + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), + true, + false + ], + [ + new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ), + new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ), + false, + false + ], + // MariaDB GTID style + [ + new MySQLMasterPos( '255-11-23', $now ), + new MySQLMasterPos( '255-11-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99', $now ), + new MySQLMasterPos( '255-11-100', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-999', $now ), + new MySQLMasterPos( '254-11-1000', $now ), + false, + false + ], + [ + new MySQLMasterPos( '255-11-23,256-12-50', $now ), + new MySQLMasterPos( '255-11-24', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ), + new MySQLMasterPos( '255-11-1000', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-23,256-12-50', $now ), + new MySQLMasterPos( '255-11-24,155-52-63', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50,257-12-50', $now ), + new MySQLMasterPos( '255-11-1000,256-12-51', $now ), + true, + false + ], + [ + new MySQLMasterPos( '255-11-99,256-12-50', $now ), + new MySQLMasterPos( '255-13-1000,256-14-49', $now ), + true, + true + ], + [ + new MySQLMasterPos( '253-11-999,255-11-999', $now ), + new MySQLMasterPos( '254-11-1000', $now ), + false, + false + ], + ]; + } + + /** + * @dataProvider provideChannelPositions + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testChannelsMatch( MySQLMasterPos $pos1, MySQLMasterPos $pos2, $matches ) { + $this->assertEquals( $matches, $pos1->channelsMatch( $pos2 ) ); + $this->assertEquals( $matches, $pos2->channelsMatch( $pos1 ) ); + + $roundtripPos = new MySQLMasterPos( (string)$pos1, 1 ); + $this->assertEquals( (string)$pos1, (string)$roundtripPos ); + } + + public static function provideChannelPositions() { + $now = microtime( true ); + + return [ + [ + new MySQLMasterPos( 'db1034-bin.000876/44', $now ), + new MySQLMasterPos( 'db1034-bin.000976/74', $now ), + true + ], + [ + new MySQLMasterPos( 'db1052-bin.000976/999', $now ), + new MySQLMasterPos( 'db1052-bin.000976/1000', $now ), + true + ], + [ + new MySQLMasterPos( 'db1066-bin.000976/9999', $now ), + new MySQLMasterPos( 'db1035-bin.000976/10000', $now ), + false + ], + [ + new MySQLMasterPos( 'db1066-bin.000976/9999', $now ), + new MySQLMasterPos( 'trump2016.000976/10000', $now ), + false + ], + ]; + } + + /** + * @dataProvider provideCommonDomainGTIDs + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testCommonGtidDomains( MySQLMasterPos $pos, MySQLMasterPos $ref, $gtids ) { + $this->assertEquals( $gtids, MySQLMasterPos::getCommonDomainGTIDs( $pos, $ref ) ); + } + + public static function provideCommonDomainGTIDs() { + return [ + [ + new MySQLMasterPos( '255-13-99,256-12-50,257-14-50', 1 ), + new MySQLMasterPos( '255-11-1000', 1 ), + [ '255-13-99' ] + ], + [ + new MySQLMasterPos( + '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' . + '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30', + 1 + ), + new MySQLMasterPos( + '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' . + '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66', + 1 + ), + [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ] + ] + ]; + } + + /** + * @dataProvider provideLagAmounts + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLag + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getLagFromPtHeartbeat + */ + public function testPtHeartbeat( $lag ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ + 'getLagDetectionMethod', 'getHeartbeatData', 'getMasterServerInfo' ] ) + ->getMock(); + + $db->method( 'getLagDetectionMethod' ) + ->willReturn( 'pt-heartbeat' ); + + $db->method( 'getMasterServerInfo' ) + ->willReturn( [ 'serverId' => 172, 'asOf' => time() ] ); + + // Fake the current time. + list( $nowSecFrac, $nowSec ) = explode( ' ', microtime() ); + $now = (float)$nowSec + (float)$nowSecFrac; + // Fake the heartbeat time. + // Work arounds for weak DataTime microseconds support. + $ptTime = $now - $lag; + $ptSec = (int)$ptTime; + $ptSecFrac = ( $ptTime - $ptSec ); + $ptDateTime = new DateTime( "@$ptSec" ); + $ptTimeISO = $ptDateTime->format( 'Y-m-d\TH:i:s' ); + $ptTimeISO .= ltrim( number_format( $ptSecFrac, 6 ), '0' ); + + $db->method( 'getHeartbeatData' ) + ->with( [ 'server_id' => 172 ] ) + ->willReturn( [ $ptTimeISO, $now ] ); + + $db->setLBInfo( 'clusterMasterHost', 'db1052' ); + $lagEst = $db->getLag(); + + $this->assertGreaterThan( $lag - 0.010, $lagEst, "Correct heatbeat lag" ); + $this->assertLessThan( $lag + 0.010, $lagEst, "Correct heatbeat lag" ); + } + + public static function provideLagAmounts() { + return [ + [ 0 ], + [ 0.3 ], + [ 6.5 ], + [ 10.1 ], + [ 200.2 ], + [ 400.7 ], + [ 600.22 ], + [ 1000.77 ], + ]; + } + + /** + * @dataProvider provideGtidData + * @covers Wikimedia\Rdbms\MySQLMasterPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos + */ + public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ + 'useGTIDs', + 'getServerGTIDs', + 'getServerRoleStatus', + 'getServerId', + 'getServerUUID' + ] ) + ->getMock(); + + $db->method( 'useGTIDs' )->willReturn( true ); + $db->method( 'getServerGTIDs' )->willReturn( $gtable ); + $db->method( 'getServerRoleStatus' )->willReturnCallback( + function ( $role ) use ( $rBLtable, $mBLtable ) { + if ( $role === 'SLAVE' ) { + return $rBLtable; + } elseif ( $role === 'MASTER' ) { + return $mBLtable; + } + + return null; + } + ); + $db->method( 'getServerId' )->willReturn( 1 ); + $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' ); + + if ( is_array( $rGTIDs ) ) { + $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getReplicaPos() ); + } + if ( is_array( $mGTIDs ) ) { + $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() ); + } else { + $this->assertEquals( false, $db->getMasterPos() ); + } + } + + public static function provideGtidData() { + return [ + // MariaDB + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => null // master + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [ + 'File' => 'host.1600', + 'Position' => '77' + ], + [], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_domain_id' => 100, + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + [ + [ + 'gtid_current_pos' => '100-13-77', + 'gtid_binlog_pos' => '100-13-77', + 'gtid_slave_pos' => '100-13-77' // replica + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], + [ '100' => '100-13-77' ], + [ '100' => '100-13-77' ] + ], + // MySQL + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ], + ], + [ + [ + 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' . + '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + // replica/master use same var + [ '2E11FA47-71CA-11E1-9E33-C80AA9429562' + => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ], + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [ + 'Relay_Master_Log_File' => 'host.1600', + 'Exec_Master_Log_Pos' => '77' + ], + [], // only a replica + [], // binlog fallback + false + ], + [ + [ + 'gtid_executed' => null, // not enabled? + 'gtid_binlog_pos' => null + ], + [], // no replication + [], // no replication + false, + false + ] + ]; + } + + /** + * @covers Wikimedia\Rdbms\MySQLMasterPos + */ + public function testSerialize() { + $pos = new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + + $pos = new MySQLMasterPos( '255-11-23', 53636363 ); + $roundtripPos = unserialize( serialize( $pos ) ); + + $this->assertEquals( $pos, $roundtripPos ); + } + + /** + * @covers Wikimedia\Rdbms\DatabaseMysqlBase::isInsertSelectSafe + * @dataProvider provideInsertSelectCases + */ + public function testInsertSelectIsSafe( $insertOpts, $selectOpts, $row, $safe ) { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getReplicationSafetyInfo' ] ) + ->getMock(); + $db->method( 'getReplicationSafetyInfo' )->willReturn( (object)$row ); + $dbw = TestingAccessWrapper::newFromObject( $db ); + + $this->assertEquals( $safe, $dbw->isInsertSelectSafe( $insertOpts, $selectOpts ) ); + } + + public function provideInsertSelectCases() { + return [ + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'ROW', + ], + true + ], + [ + [], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => '2', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [ 'LIMIT' => 100 ], + [ + 'innodb_autoinc_lock_mode' => '0', + 'binlog_format' => 'STATEMENT', + ], + false + ], + [ + [], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 2, + 'binlog_format' => 'STATEMENT', + ], + true + ], + [ + [ 'NO_AUTO_COLUMNS' ], + [], + [ + 'innodb_autoinc_lock_mode' => 0, + 'binlog_format' => 'STATEMENT', + ], + true + ], + + ]; + } + + /** + * @covers \Wikimedia\Rdbms\DatabaseMysqlBase::buildIntegerCast + */ + public function testBuildIntegerCast() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + $output = $db->buildIntegerCast( 'fieldName' ); + $this->assertSame( 'CAST( fieldName AS SIGNED )', $output ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setIndexAliases + */ + public function testIndexAliases() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] ) + ->getMock(); + $db->method( 'mysqlRealEscapeString' )->willReturnCallback( + function ( $s ) { + return str_replace( "'", "\\'", $s ); + } + ); + + $db->setIndexAliases( [ 'a_b_idx' => 'a_c_idx' ] ); + $sql = $db->selectSQLText( + 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] ); + + $this->assertEquals( + "SELECT field FROM `zend` FORCE INDEX (a_c_idx) WHERE a = 'x' ", + $sql + ); + + $db->setIndexAliases( [] ); + $sql = $db->selectSQLText( + 'zend', 'field', [ 'a' => 'x' ], __METHOD__, [ 'USE INDEX' => 'a_b_idx' ] ); + + $this->assertEquals( + "SELECT field FROM `zend` FORCE INDEX (a_b_idx) WHERE a = 'x' ", + $sql + ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setTableAliases + */ + public function testTableAliases() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'mysqlRealEscapeString', 'dbSchema', 'tablePrefix' ] ) + ->getMock(); + $db->method( 'mysqlRealEscapeString' )->willReturnCallback( + function ( $s ) { + return str_replace( "'", "\\'", $s ); + } + ); + + $db->setTableAliases( [ + 'meow' => [ 'dbname' => 'feline', 'schema' => null, 'prefix' => 'cat_' ] + ] ); + $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ ); + + $this->assertEquals( + "SELECT field FROM `feline`.`cat_meow` WHERE a = 'x' ", + $sql + ); + + $db->setTableAliases( [] ); + $sql = $db->selectSQLText( 'meow', 'field', [ 'a' => 'x' ], __METHOD__ ); + + $this->assertEquals( + "SELECT field FROM `meow` WHERE a = 'x' ", + $sql + ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php new file mode 100644 index 0000000000..0e133d8c2c --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php @@ -0,0 +1,2164 @@ +database = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => true ] ); + } + + protected function assertLastSql( $sqlText ) { + $this->assertEquals( + $sqlText, + $this->database->getLastSqls() + ); + } + + protected function assertLastSqlDb( $sqlText, DatabaseTestHelper $db ) { + $this->assertEquals( $sqlText, $db->getLastSqls() ); + } + + /** + * @dataProvider provideSelect + * @covers Wikimedia\Rdbms\Database::select + * @covers Wikimedia\Rdbms\Database::selectSQLText + * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN + * @covers Wikimedia\Rdbms\Database::useIndexClause + * @covers Wikimedia\Rdbms\Database::ignoreIndexClause + * @covers Wikimedia\Rdbms\Database::makeSelectOptions + * @covers Wikimedia\Rdbms\Database::makeOrderBy + * @covers Wikimedia\Rdbms\Database::makeGroupByWithHaving + * @covers Wikimedia\Rdbms\Database::selectFieldsOrOptionsAggregate + * @covers Wikimedia\Rdbms\Database::selectOptionsIncludeLocking + */ + public function testSelect( $sql, $sqlText ) { + $this->database->select( + $sql['tables'], + $sql['fields'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelect() { + return [ + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [ 'alias' => 'text' ], + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => 'alias = \'text\'', + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [], + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '', + ], + "SELECT field,field2 AS alias " . + "FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => '0', // T188314 + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE 0" + ], + [ + [ + // 'tables' with space prepended indicates pre-escaped table name + 'tables' => ' table LEFT JOIN table2', + 'fields' => [ 'field' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT field FROM table LEFT JOIN table2 WHERE field = 'text'" + ], + [ + [ + // Empty 'tables' is allowed + 'tables' => '', + 'fields' => [ 'SPECIAL_QUERY()' ], + ], + "SELECT SPECIAL_QUERY()" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field', 'alias' => 'field2' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + ], + "SELECT field,field2 AS alias " . + "FROM table " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + 'join_conds' => [ 't2' => [ + 'LEFT JOIN', 'tid = t2.id' + ] ], + ], + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "ORDER BY field " . + "LIMIT 1" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'GROUP BY' => 'field', 'HAVING' => 'COUNT(*) > 1' ], + 'join_conds' => [ 't2' => [ + 'LEFT JOIN', 'tid = t2.id' + ] ], + ], + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field HAVING COUNT(*) > 1 " . + "LIMIT 1" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'fields' => [ 'tid', 'field', 'alias' => 'field2', 't2.id' ], + 'conds' => [ 'alias' => 'text' ], + 'options' => [ + 'LIMIT' => 1, + 'GROUP BY' => [ 'field', 'field2' ], + 'HAVING' => [ 'COUNT(*) > 1', 'field' => 1 ] + ], + 'join_conds' => [ 't2' => [ + 'LEFT JOIN', 'tid = t2.id' + ] ], + ], + "SELECT tid,field,field2 AS alias,t2.id " . + "FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE alias = 'text' " . + "GROUP BY field,field2 HAVING (COUNT(*) > 1) AND field = '1' " . + "LIMIT 1" + ], + [ + [ + 'tables' => [ 'table' ], + 'fields' => [ 'alias' => 'field' ], + 'conds' => [ 'alias' => [ 1, 2, 3, 4 ] ], + ], + "SELECT field AS alias " . + "FROM table " . + "WHERE alias IN ('1','2','3','4')" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'USE INDEX' => [ 'table' => 'X' ] ], + ], + // No-op by default + "SELECT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'IGNORE INDEX' => [ 'table' => 'X' ] ], + ], + // No-op by default + "SELECT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'DISTINCT' ], + ], + "SELECT DISTINCT field FROM table" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'LOCK IN SHARE MODE' ], + ], + "SELECT field FROM table LOCK IN SHARE MODE" + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'EXPLAIN' => true ], + ], + 'EXPLAIN SELECT field FROM table' + ], + [ + [ + 'tables' => 'table', + 'fields' => [ 'field' ], + 'options' => [ 'FOR UPDATE' ], + ], + "SELECT field FROM table FOR UPDATE" + ], + ]; + } + + /** + * @dataProvider provideLockForUpdate + * @covers Wikimedia\Rdbms\Database::lockForUpdate + */ + public function testLockForUpdate( $sql, $sqlText ) { + $this->database->startAtomic( __METHOD__ ); + $this->database->lockForUpdate( + $sql['tables'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->database->endAtomic( __METHOD__ ); + + $this->assertLastSql( "BEGIN; $sqlText; COMMIT" ); + } + + public static function provideLockForUpdate() { + return [ + [ + [ + 'tables' => [ 'table' ], + 'conds' => [ 'field' => [ 1, 2, 3, 4 ] ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field IN ('1','2','3','4') " . + "FOR UPDATE) tmp_count" + ], + [ + [ + 'tables' => [ 'table', 't2' => 'table2' ], + 'conds' => [ 'field' => 'text' ], + 'options' => [ 'LIMIT' => 1, 'ORDER BY' => 'field' ], + 'join_conds' => [ 't2' => [ + 'LEFT JOIN', 'tid = t2.id' + ] ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table LEFT JOIN table2 t2 ON ((tid = t2.id)) " . + "WHERE field = 'text' ORDER BY field LIMIT 1 FOR UPDATE) tmp_count" + ], + [ + [ + 'tables' => 'table', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table FOR UPDATE) tmp_count" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Subquery + * @dataProvider provideSelectRowCount + * @param array $sql + * @param string $sqlText + */ + public function testSelectRowCount( $sql, $sqlText ) { + $this->database->selectRowCount( + $sql['tables'], + $sql['field'], + $sql['conds'] ?? [], + __METHOD__, + $sql['options'] ?? [], + $sql['join_conds'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideSelectRowCount() { + return [ + [ + [ + 'tables' => 'table', + 'field' => [ '*' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => [ 'field' => 'text' ], + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE field = 'text' AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => false, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => null, + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '1', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (1) AND (column IS NOT NULL) ) tmp_count" + ], + [ + [ + 'tables' => 'table', + 'field' => [ 'alias' => 'column' ], + 'conds' => '0', + ], + "SELECT COUNT(*) AS rowcount FROM " . + "(SELECT 1 FROM table WHERE (0) AND (column IS NOT NULL) ) tmp_count" + ], + ]; + } + + /** + * @dataProvider provideUpdate + * @covers Wikimedia\Rdbms\Database::update + * @covers Wikimedia\Rdbms\Database::makeUpdateOptions + * @covers Wikimedia\Rdbms\Database::makeUpdateOptionsArray + */ + public function testUpdate( $sql, $sqlText ) { + $this->database->update( + $sql['table'], + $sql['values'], + $sql['conds'], + __METHOD__, + $sql['options'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpdate() { + return [ + [ + [ + 'table' => 'table', + 'values' => [ 'field' => 'text', 'field2' => 'text2' ], + 'conds' => [ 'alias' => 'text' ], + ], + "UPDATE table " . + "SET field = 'text'" . + ",field2 = 'text2' " . + "WHERE alias = 'text'" + ], + [ + [ + 'table' => 'table', + 'values' => [ 'field = other', 'field2' => 'text2' ], + 'conds' => [ 'id' => '1' ], + ], + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2' " . + "WHERE id = '1'" + ], + [ + [ + 'table' => 'table', + 'values' => [ 'field = other', 'field2' => 'text2' ], + 'conds' => '*', + ], + "UPDATE table " . + "SET field = other" . + ",field2 = 'text2'" + ], + ]; + } + + /** + * @dataProvider provideDelete + * @covers Wikimedia\Rdbms\Database::delete + */ + public function testDelete( $sql, $sqlText ) { + $this->database->delete( + $sql['table'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDelete() { + return [ + [ + [ + 'table' => 'table', + 'conds' => [ 'alias' => 'text' ], + ], + "DELETE FROM table " . + "WHERE alias = 'text'" + ], + [ + [ + 'table' => 'table', + 'conds' => '*', + ], + "DELETE FROM table" + ], + ]; + } + + /** + * @dataProvider provideUpsert + * @covers Wikimedia\Rdbms\Database::upsert + */ + public function testUpsert( $sql, $sqlText ) { + $this->database->upsert( + $sql['table'], + $sql['rows'], + $sql['uniqueIndexes'], + $sql['set'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideUpsert() { + return [ + [ + [ + 'table' => 'upsert_table', + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + 'uniqueIndexes' => [ 'field' ], + 'set' => [ 'field' => 'set' ], + ], + "BEGIN; " . + "UPDATE upsert_table " . + "SET field = 'set' " . + "WHERE ((field = 'text')); " . + "INSERT IGNORE INTO upsert_table " . + "(field,field2) " . + "VALUES ('text','text2'); " . + "COMMIT" + ], + ]; + } + + /** + * @dataProvider provideDeleteJoin + * @covers Wikimedia\Rdbms\Database::deleteJoin + */ + public function testDeleteJoin( $sql, $sqlText ) { + $this->database->deleteJoin( + $sql['delTable'], + $sql['joinTable'], + $sql['delVar'], + $sql['joinVar'], + $sql['conds'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideDeleteJoin() { + return [ + [ + [ + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => [ 'alias' => 'text' ], + ], + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join WHERE alias = 'text'" . + ")" + ], + [ + [ + 'delTable' => 'table', + 'joinTable' => 'table_join', + 'delVar' => 'field', + 'joinVar' => 'field_join', + 'conds' => '*', + ], + "DELETE FROM table " . + "WHERE field IN (" . + "SELECT field_join FROM table_join " . + ")" + ], + ]; + } + + /** + * @dataProvider provideInsert + * @covers Wikimedia\Rdbms\Database::insert + * @covers Wikimedia\Rdbms\Database::makeInsertOptions + */ + public function testInsert( $sql, $sqlText ) { + $this->database->insert( + $sql['table'], + $sql['rows'], + __METHOD__, + $sql['options'] ?? [] + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideInsert() { + return [ + [ + [ + 'table' => 'table', + 'rows' => [ 'field' => 'text', 'field2' => 2 ], + ], + "INSERT INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ], + [ + [ + 'table' => 'table', + 'rows' => [ 'field' => 'text', 'field2' => 2 ], + 'options' => 'IGNORE', + ], + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES ('text','2')" + ], + [ + [ + 'table' => 'table', + 'rows' => [ + [ 'field' => 'text', 'field2' => 2 ], + [ 'field' => 'multi', 'field2' => 3 ], + ], + 'options' => 'IGNORE', + ], + "INSERT IGNORE INTO table " . + "(field,field2) " . + "VALUES " . + "('text','2')," . + "('multi','3')" + ], + ]; + } + + /** + * @dataProvider provideInsertSelect + * @covers Wikimedia\Rdbms\Database::insertSelect + * @covers Wikimedia\Rdbms\Database::nativeInsertSelect + */ + public function testInsertSelect( $sql, $sqlTextNative, $sqlSelect, $sqlInsert ) { + $this->database->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + $sql['insertOptions'] ?? [], + $sql['selectOptions'] ?? [], + $sql['selectJoinConds'] ?? [] + ); + $this->assertLastSql( $sqlTextNative ); + + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $dbWeb->forceNextResult( [ + array_flip( array_keys( $sql['varMap'] ) ) + ] ); + $dbWeb->insertSelect( + $sql['destTable'], + $sql['srcTable'], + $sql['varMap'], + $sql['conds'], + __METHOD__, + $sql['insertOptions'] ?? [], + $sql['selectOptions'] ?? [], + $sql['selectJoinConds'] ?? [] + ); + $this->assertLastSqlDb( implode( '; ', [ $sqlSelect, 'BEGIN', $sqlInsert, 'COMMIT' ] ), $dbWeb ); + } + + public static function provideInsertSelect() { + return [ + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => '*', + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2'", + "SELECT field_select AS field_insert,field2 AS field FROM " . + "select_table WHERE field = '2' FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => 'select_table', + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + 'insertOptions' => 'IGNORE', + 'selectOptions' => [ 'ORDER BY' => 'field' ], + ], + "INSERT IGNORE INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table " . + "WHERE field = '2' " . + "ORDER BY field", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table WHERE field = '2' ORDER BY field FOR UPDATE", + "INSERT IGNORE INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + [ + [ + 'destTable' => 'insert_table', + 'srcTable' => [ 'select_table1', 'select_table2' ], + 'varMap' => [ 'field_insert' => 'field_select', 'field' => 'field2' ], + 'conds' => [ 'field' => 2 ], + 'insertOptions' => [ 'NO_AUTO_COLUMNS' ], + 'selectOptions' => [ 'ORDER BY' => 'field', 'FORCE INDEX' => [ 'select_table1' => 'index1' ] ], + 'selectJoinConds' => [ + 'select_table2' => [ 'LEFT JOIN', [ 'select_table1.foo = select_table2.bar' ] ], + ], + ], + "INSERT INTO insert_table " . + "(field_insert,field) " . + "SELECT field_select,field2 " . + "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " . + "WHERE field = '2' " . + "ORDER BY field", + "SELECT field_select AS field_insert,field2 AS field " . + "FROM select_table1 LEFT JOIN select_table2 ON ((select_table1.foo = select_table2.bar)) " . + "WHERE field = '2' ORDER BY field FOR UPDATE", + "INSERT INTO insert_table (field_insert,field) VALUES ('0','1')" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::insertSelect + * @covers Wikimedia\Rdbms\Database::nativeInsertSelect + */ + public function testInsertSelectBatching() { + $dbWeb = new DatabaseTestHelper( __CLASS__, [ 'cliMode' => false ] ); + $rows = []; + for ( $i = 0; $i <= 25000; $i++ ) { + $rows[] = [ 'field' => $i ]; + } + $dbWeb->forceNextResult( $rows ); + $dbWeb->insertSelect( + 'insert_table', + 'select_table', + [ 'field' => 'field2' ], + '*', + __METHOD__ + ); + $this->assertLastSqlDb( implode( '; ', [ + 'SELECT field2 AS field FROM select_table FOR UPDATE', + 'BEGIN', + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 0, 9999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 10000, 19999 ) ) . "')", + "INSERT INTO insert_table (field) VALUES ('" . implode( "'),('", range( 20000, 25000 ) ) . "')", + 'COMMIT' + ] ), $dbWeb ); + } + + /** + * @dataProvider provideReplace + * @covers Wikimedia\Rdbms\Database::replace + */ + public function testReplace( $sql, $sqlText ) { + $this->database->replace( + $sql['table'], + $sql['uniqueIndexes'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideReplace() { + return [ + [ + [ + 'table' => 'replace_table', + 'uniqueIndexes' => [ 'field' ], + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + ], + "BEGIN; DELETE FROM replace_table " . + "WHERE (field = 'text'); " . + "INSERT INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ], + 'rows' => [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], + ], + "BEGIN; DELETE FROM module_deps " . + "WHERE (md_module = 'module' AND md_skin = 'skin'); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ [ 'md_module', 'md_skin' ] ], + 'rows' => [ + [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], [ + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ], + ], + ], + "BEGIN; DELETE FROM module_deps " . + "WHERE (md_module = 'module' AND md_skin = 'skin'); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE (md_module = 'module2' AND md_skin = 'skin2'); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [ 'md_module', 'md_skin' ], + 'rows' => [ + [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], [ + 'md_module' => 'module2', + 'md_skin' => 'skin2', + 'md_deps' => 'deps2', + ], + ], + ], + "BEGIN; DELETE FROM module_deps " . + "WHERE (md_module = 'module') OR (md_skin = 'skin'); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); " . + "DELETE FROM module_deps " . + "WHERE (md_module = 'module2') OR (md_skin = 'skin2'); " . + "INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module2','skin2','deps2'); COMMIT" + ], + [ + [ + 'table' => 'module_deps', + 'uniqueIndexes' => [], + 'rows' => [ + 'md_module' => 'module', + 'md_skin' => 'skin', + 'md_deps' => 'deps', + ], + ], + "BEGIN; INSERT INTO module_deps " . + "(md_module,md_skin,md_deps) " . + "VALUES ('module','skin','deps'); COMMIT" + ], + ]; + } + + /** + * @dataProvider provideNativeReplace + * @covers Wikimedia\Rdbms\Database::nativeReplace + */ + public function testNativeReplace( $sql, $sqlText ) { + $this->database->nativeReplace( + $sql['table'], + $sql['rows'], + __METHOD__ + ); + $this->assertLastSql( $sqlText ); + } + + public static function provideNativeReplace() { + return [ + [ + [ + 'table' => 'replace_table', + 'rows' => [ 'field' => 'text', 'field2' => 'text2' ], + ], + "REPLACE INTO replace_table " . + "(field,field2) " . + "VALUES ('text','text2')" + ], + ]; + } + + /** + * @dataProvider provideConditional + * @covers Wikimedia\Rdbms\Database::conditional + */ + public function testConditional( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->conditional( + $sql['conds'], + $sql['true'], + $sql['false'] + ) ), $sqlText ); + } + + public static function provideConditional() { + return [ + [ + [ + 'conds' => [ 'field' => 'text' ], + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field = 'text' THEN 1 ELSE NULL END)" + ], + [ + [ + 'conds' => [ 'field' => 'text', 'field2' => 'anothertext' ], + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field = 'text' AND field2 = 'anothertext' THEN 1 ELSE NULL END)" + ], + [ + [ + 'conds' => 'field=1', + 'true' => 1, + 'false' => 'NULL', + ], + "(CASE WHEN field=1 THEN 1 ELSE NULL END)" + ], + ]; + } + + /** + * @dataProvider provideBuildConcat + * @covers Wikimedia\Rdbms\Database::buildConcat + */ + public function testBuildConcat( $stringList, $sqlText ) { + $this->assertEquals( trim( $this->database->buildConcat( + $stringList + ) ), $sqlText ); + } + + public static function provideBuildConcat() { + return [ + [ + [ 'field', 'field2' ], + "CONCAT(field,field2)" + ], + [ + [ "'test'", 'field2' ], + "CONCAT('test',field2)" + ], + ]; + } + + /** + * @dataProvider provideBuildLike + * @covers Wikimedia\Rdbms\Database::buildLike + * @covers Wikimedia\Rdbms\Database::escapeLikeInternal + */ + public function testBuildLike( $array, $sqlText ) { + $this->assertEquals( trim( $this->database->buildLike( + $array + ) ), $sqlText ); + } + + public static function provideBuildLike() { + return [ + [ + 'text', + "LIKE 'text' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '%' ) ], + "LIKE 'text%' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '%' ), 'text2' ], + "LIKE 'text%text2' ESCAPE '`'" + ], + [ + [ 'text', new LikeMatch( '_' ) ], + "LIKE 'text_' ESCAPE '`'" + ], + [ + 'more_text', + "LIKE 'more`_text' ESCAPE '`'" + ], + [ + [ 'C:\\Windows\\', new LikeMatch( '%' ) ], + "LIKE 'C:\\Windows\\%' ESCAPE '`'" + ], + [ + [ 'accent`_test`', new LikeMatch( '%' ) ], + "LIKE 'accent```_test``%' ESCAPE '`'" + ], + ]; + } + + /** + * @dataProvider provideUnionQueries + * @covers Wikimedia\Rdbms\Database::unionQueries + */ + public function testUnionQueries( $sql, $sqlText ) { + $this->assertEquals( trim( $this->database->unionQueries( + $sql['sqls'], + $sql['all'] + ) ), $sqlText ); + } + + public static function provideUnionQueries() { + return [ + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL' ], + 'all' => true, + ], + "(RAW SQL) UNION ALL (RAW2SQL)" + ], + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL' ], + 'all' => false, + ], + "(RAW SQL) UNION (RAW2SQL)" + ], + [ + [ + 'sqls' => [ 'RAW SQL', 'RAW2SQL', 'RAW3SQL' ], + 'all' => false, + ], + "(RAW SQL) UNION (RAW2SQL) UNION (RAW3SQL)" + ], + ]; + } + + /** + * @dataProvider provideUnionConditionPermutations + * @covers Wikimedia\Rdbms\Database::unionConditionPermutations + */ + public function testUnionConditionPermutations( $params, $expect ) { + if ( isset( $params['unionSupportsOrderAndLimit'] ) ) { + $this->database->setUnionSupportsOrderAndLimit( $params['unionSupportsOrderAndLimit'] ); + } + + $sql = trim( $this->database->unionConditionPermutations( + $params['table'], + $params['vars'], + $params['permute_conds'], + $params['extra_conds'] ?? '', + 'FNAME', + $params['options'] ?? [], + $params['join_conds'] ?? [] + ) ); + $this->assertEquals( $expect, $sql ); + } + + public static function provideUnionConditionPermutations() { + // phpcs:disable Generic.Files.LineLength + return [ + [ + [ + 'table' => [ 'table1', 'table2' ], + 'vars' => [ 'field1', 'alias' => 'field2' ], + 'permute_conds' => [ + 'field3' => [ 1, 2, 3 ], + 'duplicates' => [ 4, 5, 4 ], + 'empty' => [], + 'single' => [ 0 ], + ], + 'extra_conds' => 'table2.bar > 23', + 'options' => [ + 'ORDER BY' => [ 'field1', 'alias' ], + 'INNER ORDER BY' => [ 'field1', 'field2' ], + 'LIMIT' => 100, + ], + 'join_conds' => [ + 'table2' => [ 'JOIN', 'table1.foo_id = table2.foo_id' ], + ], + ], + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '1' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '2' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '4' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) UNION ALL " . + "(SELECT field1,field2 AS alias FROM table1 JOIN table2 ON ((table1.foo_id = table2.foo_id)) WHERE field3 = '3' AND duplicates = '5' AND single = '0' AND (table2.bar > 23) ORDER BY field1,field2 LIMIT 100 ) " . + "ORDER BY field1,alias LIMIT 100" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1, 2, 3 ], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'NOTALL', + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ORDER BY foo_id LIMIT 25 ) " . + "ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1, 2, 3 ], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'NOTALL' => true, + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + 'unionSupportsOrderAndLimit' => false, + ], + "(SELECT foo_id FROM foo WHERE bar = '1' AND baz IS NULL ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '2' AND baz IS NULL ) UNION " . + "(SELECT foo_id FROM foo WHERE bar = '3' AND baz IS NULL ) " . + "ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [], + ], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + ], + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [ + 'bar' => [ 1 ], + ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + ], + ], + "SELECT foo_id FROM foo WHERE bar = '1' ORDER BY foo_id LIMIT 150,25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + 'INNER ORDER BY' => [ 'bar_id' ], + ], + ], + "(SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY bar_id LIMIT 175 ) ORDER BY foo_id LIMIT 150,25" + ], + [ + [ + 'table' => 'foo', + 'vars' => [ 'foo_id' ], + 'permute_conds' => [], + 'extra_conds' => [ 'baz' => null ], + 'options' => [ + 'ORDER BY' => [ 'foo_id' ], + 'LIMIT' => 25, + 'OFFSET' => 150, + 'INNER ORDER BY' => [ 'bar_id' ], + ], + 'unionSupportsOrderAndLimit' => false, + ], + "SELECT foo_id FROM foo WHERE baz IS NULL ORDER BY foo_id LIMIT 150,25" + ], + ]; + // phpcs:enable + } + + /** + * @covers Wikimedia\Rdbms\Database::commit + * @covers Wikimedia\Rdbms\Database::doCommit + */ + public function testTransactionCommit() { + $this->database->begin( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::rollback + * @covers Wikimedia\Rdbms\Database::doRollback + */ + public function testTransactionRollback() { + $this->database->begin( __METHOD__ ); + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::dropTable + */ + public function testDropTable() { + $this->database->setExistingTables( [ 'table' ] ); + $this->database->dropTable( 'table', __METHOD__ ); + $this->assertLastSql( 'DROP TABLE table CASCADE' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::dropTable + */ + public function testDropNonExistingTable() { + $this->assertFalse( + $this->database->dropTable( 'non_existing', __METHOD__ ) + ); + } + + /** + * @dataProvider provideMakeList + * @covers Wikimedia\Rdbms\Database::makeList + */ + public function testMakeList( $list, $mode, $sqlText ) { + $this->assertEquals( trim( $this->database->makeList( + $list, $mode + ) ), $sqlText ); + } + + public static function provideMakeList() { + return [ + [ + [ 'value', 'value2' ], + LIST_COMMA, + "'value','value2'" + ], + [ + [ 'field', 'field2' ], + LIST_NAMES, + "field,field2" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_AND, + "field = 'value' AND field2 = 'value2'" + ], + [ + [ 'field' => null, "field2 != 'value2'" ], + LIST_AND, + "field IS NULL AND (field2 != 'value2')" + ], + [ + [ 'field' => [ 'value', null, 'value2' ], 'field2' => 'value2' ], + LIST_AND, + "(field IN ('value','value2') OR field IS NULL) AND field2 = 'value2'" + ], + [ + [ 'field' => [ null ], 'field2' => null ], + LIST_AND, + "field IS NULL AND field2 IS NULL" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_OR, + "field = 'value' OR field2 = 'value2'" + ], + [ + [ 'field' => 'value', 'field2' => null ], + LIST_OR, + "field = 'value' OR field2 IS NULL" + ], + [ + [ 'field' => [ 'value', 'value2' ], 'field2' => [ 'value' ] ], + LIST_OR, + "field IN ('value','value2') OR field2 = 'value'" + ], + [ + [ 'field' => [ null, 'value', null, 'value2' ], "field2 != 'value2'" ], + LIST_OR, + "(field IN ('value','value2') OR field IS NULL) OR (field2 != 'value2')" + ], + [ + [ 'field' => 'value', 'field2' => 'value2' ], + LIST_SET, + "field = 'value',field2 = 'value2'" + ], + [ + [ 'field' => 'value', 'field2' => null ], + LIST_SET, + "field = 'value',field2 = NULL" + ], + [ + [ 'field' => 'value', "field2 != 'value2'" ], + LIST_SET, + "field = 'value',field2 != 'value2'" + ], + ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::registerTempTableWrite + */ + public function testSessionTempTables() { + $temp1 = $this->database->tableName( 'tmp_table_1' ); + $temp2 = $this->database->tableName( 'tmp_table_2' ); + $temp3 = $this->database->tableName( 'tmp_table_3' ); + + $this->database->query( "CREATE TEMPORARY TABLE $temp1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp2 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE $temp3 LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->dropTable( 'tmp_table_1', __METHOD__ ); + $this->database->dropTable( 'tmp_table_2', __METHOD__ ); + $this->database->dropTable( 'tmp_table_3', __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "CREATE TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "CREATE TEMPORARY TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertTrue( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertTrue( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + + $this->database->query( "DROP TEMPORARY TABLE tmp_table_1 LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TEMPORARY TABLE 'tmp_table_2' LIKE orig_tbl", __METHOD__ ); + $this->database->query( "DROP TABLE `tmp_table_3` LIKE orig_tbl", __METHOD__ ); + + $this->assertFalse( $this->database->tableExists( "tmp_table_1", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_2", __METHOD__ ) ); + $this->assertFalse( $this->database->tableExists( "tmp_table_3", __METHOD__ ) ); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTRING(someField FROM 1 FOR 2)' ]; + yield [ 'someField', 1, null, 'SUBSTRING(someField FROM 1)' ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $output = $this->database->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\Database::buildSubstring + * @covers Wikimedia\Rdbms\Database::assertBuildSubstringParams + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $this->setExpectedException( InvalidArgumentException::class ); + $this->database->buildSubstring( 'foo', $start, $length ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::buildIntegerCast + */ + public function testBuildIntegerCast() { + $output = $this->database->buildIntegerCast( 'fieldName' ); + $this->assertSame( 'CAST( fieldName AS INTEGER )', $output ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSections() { + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $noOpCallack = function () { + }; + + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->doAtomicSection( __METHOD__, $noOpCallack ); + $this->assertLastSql( 'BEGIN; COMMIT' ); + + $this->database->begin( __METHOD__ ); + $this->database->doAtomicSection( __METHOD__, $noOpCallack, IDatabase::ATOMIC_CANCELABLE ); + $this->database->rollback( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' ); + + $fname = __METHOD__; + $triggerMap = [ + '-' => '-', + IDatabase::TRIGGER_COMMIT => 'tCommit', + IDatabase::TRIGGER_ROLLBACK => 'tRollback' + ]; + $pcCallback = function ( IDatabase $db ) use ( $fname ) { + $this->database->query( "SELECT 0", $fname ); + }; + $callback1 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 1, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback2 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 2, {$triggerMap[$trigger]} AS t", $fname ); + }; + $callback3 = function ( $trigger = '-' ) use ( $fname, $triggerMap ) { + $this->database->query( "SELECT 3, {$triggerMap[$trigger]} AS t", $fname ); + }; + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'SELECT 0', + 'SELECT 0', + 'COMMIT' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->onTransactionResolution( $callback1, __METHOD__ ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $callback2, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tCommit AS t' + ] ) ); + + $makeCallback = function ( $id ) use ( $fname, $triggerMap ) { + return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) { + $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname ); + }; + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT', + 'SELECT 1, tRollback AS t' + ] ) ); + + $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 1 ), __METHOD__ ); + $this->database->startAtomic( __METHOD__ . '_level2' ); + $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionResolution( $makeCallback( 2 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->onTransactionResolution( $makeCallback( 3 ), __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_level3' ); + $this->database->endAtomic( __METHOD__ . '_level2' ); + $this->database->onTransactionResolution( $makeCallback( 4 ), __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_level1' ); + $this->assertLastSql( implode( "; ", [ + 'BEGIN', + 'SAVEPOINT wikimedia_rdbms_atomic1', + 'SAVEPOINT wikimedia_rdbms_atomic2', + 'RELEASE SAVEPOINT wikimedia_rdbms_atomic2', + 'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1', + 'COMMIT; SELECT 1, tCommit AS t', + 'SELECT 2, tRollback AS t', + 'SELECT 3, tRollback AS t', + 'SELECT 4, tCommit AS t' + ] ) ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsRecovery() { + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + $this->database->startAtomic( 'inner_func1' ); + $this->database->startAtomic( 'inner_func2' ); + + throw new RuntimeException( 'Test exception' ); + }, + IDatabase::ATOMIC_CANCELABLE + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + + $this->database->begin( __METHOD__ ); + try { + $this->database->doAtomicSection( + __METHOD__, + function () { + throw new RuntimeException( 'Test exception' ); + } + ); + $this->fail( 'Test exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( 'Test exception', $ex->getMessage() ); + } + try { + $this->database->commit( __METHOD__ ); + $this->fail( 'Test exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + $this->database->rollback( __METHOD__ ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsCallbackCancellation() { + $fname = __METHOD__; + $callback1Called = null; + $callback1 = function ( $trigger = '-' ) use ( $fname, &$callback1Called ) { + $callback1Called = $trigger; + $this->database->query( "SELECT 1", $fname ); + }; + $callback2Called = null; + $callback2 = function ( $trigger = '-' ) use ( $fname, &$callback2Called ) { + $callback2Called = $trigger; + $this->database->query( "SELECT 2", $fname ); + }; + $callback3Called = null; + $callback3 = function ( $trigger = '-' ) use ( $fname, &$callback3Called ) { + $callback3Called = $trigger; + $this->database->query( "SELECT 3", $fname ); + }; + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__, $atomicId ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId ); + } catch ( DBUnexpectedError $e ) { + $m = __METHOD__; + $this->assertSame( + "Invalid atomic section ended (got {$m}_X but expected {$m}).", + $e->getMessage() + ); + } + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $callback1Called = null; + $callback2Called = null; + $callback3Called = null; + $this->database->startAtomic( __METHOD__ . '_outer' ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->startAtomic( __METHOD__ . '_inner' ); + $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ ); + $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ ); + $this->database->onTransactionResolution( $callback3, __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ . '_inner' ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->endAtomic( __METHOD__ . '_outer' ); + $this->assertNull( $callback1Called ); + $this->assertNull( $callback2Called ); + $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::doSavepoint + * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint + * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint + * @covers \Wikimedia\Rdbms\Database::startAtomic + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + * @covers \Wikimedia\Rdbms\Database::doAtomicSection + */ + public function testAtomicSectionsTrxRound() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SELECT 1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' ); + } + + public static function provideAtomicSectionMethodsForErrors() { + return [ + [ 'endAtomic' ], + [ 'cancelAtomic' ], + ]; + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testNoAtomicSection( $method ) { + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'No atomic section is open (got ' . __METHOD__ . ').', + $ex->getMessage() + ); + } + } + + /** + * @dataProvider provideAtomicSectionMethodsForErrors + * @covers \Wikimedia\Rdbms\Database::endAtomic + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testInvalidAtomicSectionEnded( $method ) { + $this->database->startAtomic( __METHOD__ . 'X' ); + try { + $this->database->$method( __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Invalid atomic section ended (got ' . __METHOD__ . ' but expected ' . + __METHOD__ . 'X).', + $ex->getMessage() + ); + } + } + + /** + * @covers \Wikimedia\Rdbms\Database::cancelAtomic + */ + public function testUncancellableAtomicSection() { + $this->database->startAtomic( __METHOD__ ); + try { + $this->database->cancelAtomic( __METHOD__ ); + $this->database->select( 'test', '1', [], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $ex ) { + $this->assertSame( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $ex->getMessage() + ); + } + } + + /** + * @expectedException \Wikimedia\Rdbms\DBTransactionStateError + * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed + */ + public function testTransactionErrorState1() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->begin( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionErrorState2() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + + $this->database->startAtomic( __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->rollback( __METHOD__ ); + $this->assertEquals( 0, $this->database->trxLevel() ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; ROLLBACK' ); + + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + $this->database->update( 'y', [ 'a' => 1 ], [ 'field' => 1 ], __METHOD__ ); + $wrapper->trxStatus = Database::STATUS_TRX_ERROR; + $this->database->cancelAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->startAtomic( __METHOD__ ); + $this->database->delete( 'y', [ 'field' => 1 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->database->commit( __METHOD__ ); + // phpcs:ignore Generic.Files.LineLength + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; UPDATE y SET a = \'1\' WHERE field = \'1\'; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM y WHERE field = \'1\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel(), 'Use after rollback()' ); + + // Next transaction + $this->database->startAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_OK, $wrapper->trxStatus() ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->endAtomic( __METHOD__ ); + $this->assertEquals( Database::STATUS_TRX_NONE, $wrapper->trxStatus() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; COMMIT' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testImplicitTransactionRollback() { + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness' ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + + $this->database->setFlag( Database::DBO_TRX ); + + // Implicit transaction does not get silently rolled back + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + call_user_func( $doError ); + try { + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $e ) { + $this->assertEquals( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $e->getMessage() + ); + } + try { + $this->database->commit( __METHOD__, Database::FLUSHING_INTERNAL ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionError $e ) { + $this->assertEquals( + 'Cannot execute query from ' . __METHOD__ . ' while transaction status is ERROR.', + $e->getMessage() + ); + } + $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL ); + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK' ); + + // Likewise if there were prior writes + $this->database->begin( __METHOD__, Database::TRANSACTION_INTERNAL ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + call_user_func( $doError ); + try { + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBTransactionStateError $e ) { + } + $this->database->rollback( __METHOD__, Database::FLUSHING_INTERNAL ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'1\'; DELETE FROM error WHERE 1; ROLLBACK' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::query + */ + public function testTransactionStatementRollbackIgnoring() { + $wrapper = TestingAccessWrapper::newFromObject( $this->database ); + $warning = []; + $wrapper->deprecationLogger = function ( $msg ) use ( &$warning ) { + $warning[] = $msg; + }; + + $doError = function () { + $this->database->forceNextQueryError( 666, 'Evilness', [ + 'wasKnownStatementRollbackError' => true, + ] ); + try { + $this->database->delete( 'error', '1', __CLASS__ . '::SomeCaller' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBError $e ) { + $this->assertSame( 666, $e->errno ); + } + }; + $expectWarning = 'Caller from ' . __METHOD__ . + ' ignored an error originally raised from ' . __CLASS__ . '::SomeCaller: [666] Evilness'; + + // Rollback doesn't raise a warning + $warning = []; + $this->database->startAtomic( __METHOD__ ); + call_user_func( $doError ); + $this->database->rollback( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; ROLLBACK; DELETE FROM x WHERE field = \'1\'' ); + + // cancelAtomic() doesn't raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + $this->database->startAtomic( __METHOD__, Database::ATOMIC_CANCELABLE ); + call_user_func( $doError ); + $this->database->cancelAtomic( __METHOD__ ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM error WHERE 1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + + // Commit does raise a warning + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; COMMIT' ); + + // Deprecation only gets raised once + $warning = []; + $this->database->begin( __METHOD__ ); + call_user_func( $doError ); + $this->database->delete( 'x', [ 'field' => 1 ], __METHOD__ ); + $this->database->commit( __METHOD__ ); + $this->assertSame( [ $expectWarning ], $warning ); + // phpcs:ignore + $this->assertLastSql( 'BEGIN; DELETE FROM error WHERE 1; DELETE FROM x WHERE field = \'1\'; COMMIT' ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose1() { + $fname = __METHOD__; + $this->database->begin( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->onTransactionResolution( function () use ( $fname ) { + $this->database->query( 'SELECT 2', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + try { + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + "Wikimedia\Rdbms\Database::close: transaction is still open (from $fname).", + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK; SELECT 2' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose2() { + try { + $fname = __METHOD__; + $this->database->startAtomic( __METHOD__ ); + $this->database->onTransactionCommitOrIdle( function () use ( $fname ) { + $this->database->query( 'SELECT 1', $fname ); + } ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: atomic sections ' . + 'DatabaseSQLTest::testPrematureClose2 are still open.', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose3() { + try { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->fail( 'Expected exception not thrown' ); + } catch ( DBUnexpectedError $ex ) { + $this->assertSame( + 'Wikimedia\Rdbms\Database::close: ' . + 'mass commit/rollback of peer transaction required (DBO_TRX set).', + $ex->getMessage() + ); + } + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers \Wikimedia\Rdbms\Database::close + */ + public function testPrematureClose4() { + $this->database->setFlag( IDatabase::DBO_TRX ); + $this->database->query( 'SELECT 1', __METHOD__ ); + $this->assertEquals( 1, $this->database->trxLevel() ); + $this->database->close(); + $this->database->clearFlag( IDatabase::DBO_TRX ); + + $this->assertFalse( $this->database->isOpen() ); + $this->assertLastSql( 'BEGIN; SELECT 1; ROLLBACK' ); + $this->assertEquals( 0, $this->database->trxLevel() ); + } + + /** + * @covers Wikimedia\Rdbms\Database::selectFieldValues() + */ + public function testSelectFieldValues() { + $this->database->forceNextResult( [ + (object)[ 'value' => 'row1' ], + (object)[ 'value' => 'row2' ], + (object)[ 'value' => 'row3' ], + ] ); + + $this->assertSame( + [ 'row1', 'row2', 'row3' ], + $this->database->selectFieldValues( 'table', 'table.field', 'conds', __METHOD__ ) + ); + $this->assertLastSql( 'SELECT table.field AS value FROM table WHERE conds' ); + } +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php new file mode 100644 index 0000000000..a886d6bf76 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseSqliteRdbmsTest.php @@ -0,0 +1,60 @@ +getMockBuilder( DatabaseSqlite::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + } + + public function provideBuildSubstring() { + yield [ 'someField', 1, 2, 'SUBSTR(someField,1,2)' ]; + yield [ 'someField', 1, null, 'SUBSTR(someField,1)' ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring + * @dataProvider provideBuildSubstring + */ + public function testBuildSubstring( $input, $start, $length, $expected ) { + $dbMock = $this->getMockDb(); + $output = $dbMock->buildSubstring( $input, $start, $length ); + $this->assertSame( $expected, $output ); + } + + public function provideBuildSubstring_invalidParams() { + yield [ -1, 1 ]; + yield [ 1, -1 ]; + yield [ 1, 'foo' ]; + yield [ 'foo', 1 ]; + yield [ null, 1 ]; + yield [ 0, 1 ]; + } + + /** + * @covers Wikimedia\Rdbms\DatabaseSqlite::buildSubstring + * @dataProvider provideBuildSubstring_invalidParams + */ + public function testBuildSubstring_invalidParams( $start, $length ) { + $dbMock = $this->getMockDb(); + $this->setExpectedException( InvalidArgumentException::class ); + $dbMock->buildSubstring( 'foo', $start, $length ); + } + +} diff --git a/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php new file mode 100644 index 0000000000..8b24791ca6 --- /dev/null +++ b/tests/phpunit/includes/libs/rdbms/database/DatabaseTest.php @@ -0,0 +1,707 @@ +db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() ); + } + + /** + * @dataProvider provideAddQuotes + * @covers Wikimedia\Rdbms\Database::factory + */ + public function testFactory() { + $m = Database::NEW_UNCONNECTED; // no-connect mode + $p = [ 'host' => 'localhost', 'user' => 'me', 'password' => 'myself', 'dbname' => 'i' ]; + + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'mysqli', $p, $m ) ); + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySqli', $p, $m ) ); + $this->assertInstanceOf( DatabaseMysqli::class, Database::factory( 'MySQLi', $p, $m ) ); + $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'postgres', $p, $m ) ); + $this->assertInstanceOf( DatabasePostgres::class, Database::factory( 'Postgres', $p, $m ) ); + + $x = $p + [ 'port' => 10000, 'UseWindowsAuth' => false ]; + $this->assertInstanceOf( DatabaseMssql::class, Database::factory( 'mssql', $x, $m ) ); + + $x = $p + [ 'dbFilePath' => 'some/file.sqlite' ]; + $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); + $x = $p + [ 'dbDirectory' => 'some/file' ]; + $this->assertInstanceOf( DatabaseSqlite::class, Database::factory( 'sqlite', $x, $m ) ); + } + + public static function provideAddQuotes() { + return [ + [ null, 'NULL' ], + [ 1234, "'1234'" ], + [ 1234.5678, "'1234.5678'" ], + [ 'string', "'string'" ], + [ 'string\'s cause trouble', "'string\'s cause trouble'" ], + ]; + } + + /** + * @dataProvider provideAddQuotes + * @covers Wikimedia\Rdbms\Database::addQuotes + */ + public function testAddQuotes( $input, $expected ) { + $this->assertEquals( $expected, $this->db->addQuotes( $input ) ); + } + + public static function provideTableName() { + // Formatting is mostly ignored since addIdentifierQuotes is abstract. + // For testing of addIdentifierQuotes, see actual Database subclas tests. + return [ + 'local' => [ + 'tablename', + 'tablename', + 'quoted', + ], + 'local-raw' => [ + 'tablename', + 'tablename', + 'raw', + ], + 'shared' => [ + 'sharedb.tablename', + 'tablename', + 'quoted', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], + ], + 'shared-raw' => [ + 'sharedb.tablename', + 'tablename', + 'raw', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ], + ], + 'shared-prefix' => [ + 'sharedb.sh_tablename', + 'tablename', + 'quoted', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], + ], + 'shared-prefix-raw' => [ + 'sharedb.sh_tablename', + 'tablename', + 'raw', + [ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ], + ], + 'foreign' => [ + 'databasename.tablename', + 'databasename.tablename', + 'quoted', + ], + 'foreign-raw' => [ + 'databasename.tablename', + 'databasename.tablename', + 'raw', + ], + ]; + } + + /** + * @dataProvider provideTableName + * @covers Wikimedia\Rdbms\Database::tableName + */ + public function testTableName( $expected, $table, $format, array $alias = null ) { + if ( $alias ) { + $this->db->setTableAliases( [ $table => $alias ] ); + } + $this->assertEquals( + $expected, + $this->db->tableName( $table, $format ?: 'quoted' ) + ); + } + + public function provideTableNamesWithIndexClauseOrJOIN() { + return [ + 'one-element array' => [ + [ 'table' ], [], 'table ' + ], + 'comma join' => [ + [ 'table1', 'table2' ], [], 'table1,table2 ' + ], + 'real join' => [ + [ 'table1', 'table2' ], + [ 'table2' => [ 'LEFT JOIN', 't1_id = t2_id' ] ], + 'table1 LEFT JOIN table2 ON ((t1_id = t2_id))' + ], + 'real join with multiple conditionals' => [ + [ 'table1', 'table2' ], + [ 'table2' => [ 'LEFT JOIN', [ 't1_id = t2_id', 't2_x = \'X\'' ] ] ], + 'table1 LEFT JOIN table2 ON ((t1_id = t2_id) AND (t2_x = \'X\'))' + ], + 'join with parenthesized group' => [ + [ 'table1', 'n' => [ 'table2', 'table3' ] ], + [ + 'table3' => [ 'JOIN', 't2_id = t3_id' ], + 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], + ], + 'table1 LEFT JOIN (table2 JOIN table3 ON ((t2_id = t3_id))) ON ((t1_id = t2_id))' + ], + 'join with degenerate parenthesized group' => [ + [ 'table1', 'n' => [ 't2' => 'table2' ] ], + [ + 'n' => [ 'LEFT JOIN', 't1_id = t2_id' ], + ], + 'table1 LEFT JOIN table2 t2 ON ((t1_id = t2_id))' + ], + ]; + } + + /** + * @dataProvider provideTableNamesWithIndexClauseOrJOIN + * @covers Wikimedia\Rdbms\Database::tableNamesWithIndexClauseOrJOIN + */ + public function testTableNamesWithIndexClauseOrJOIN( $tables, $join_conds, $expect ) { + $clause = TestingAccessWrapper::newFromObject( $this->db ) + ->tableNamesWithIndexClauseOrJOIN( $tables, [], [], $join_conds ); + $this->assertSame( $expect, $clause ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionIdle() { + $db = $this->db; + + $db->clearFlag( DBO_TRX ); + $called = false; + $flagSet = null; + $callback = function ( $trigger, IDatabase $db ) use ( &$flagSet, &$called ) { + $called = true; + $flagSet = $db->getFlag( DBO_TRX ); + }; + + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); + + $flagSet = null; + $called = false; + $db->startAtomic( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Callback not reached during TRX' ); + $db->endAtomic( __METHOD__ ); + + $this->assertTrue( $called, 'Callback reached after COMMIT' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + + $db->clearFlag( DBO_TRX ); + $db->onTransactionCommitOrIdle( + function ( $trigger, IDatabase $db ) { + $db->setFlag( DBO_TRX ); + }, + __METHOD__ + ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionIdle_TRX() { + $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'ping' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( '' ); + $db->setFlag( DBO_TRX ); + + $lbFactory = LBFactorySingle::newFromConnection( $db ); + // Ask for the connection so that LB sets internal state + // about this connection being the master connection + $lb = $lbFactory->getMainLB(); + $conn = $lb->openConnection( $lb->getWriterIndex() ); + $this->assertSame( $db, $conn, 'Same DB instance' ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); + + $called = false; + $flagSet = null; + $callback = function () use ( $db, &$flagSet, &$called ) { + $called = true; + $flagSet = $db->getFlag( DBO_TRX ); + }; + + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); + $this->assertFalse( $flagSet, 'DBO_TRX off in callback' ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertTrue( $called, 'Called when lb-transaction is committed' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->rollbackMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called in next round commit' ); + + $db->setFlag( DBO_TRX ); + try { + $db->onTransactionCommitOrIdle( function () { + throw new RuntimeException( 'test' ); + } ); + $this->fail( "Exception not thrown" ); + } catch ( RuntimeException $e ) { + $this->assertTrue( $db->getFlag( DBO_TRX ) ); + } + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks + */ + public function testTransactionPreCommitOrIdle() { + $db = $this->getMockDB( [ 'isOpen' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->clearFlag( DBO_TRX ); + + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX is not set' ); + + $called = false; + $db->onTransactionPreCommitOrIdle( + function ( IDatabase $db ) use ( &$called ) { + $called = true; + }, + __METHOD__ + ); + $this->assertTrue( $called, 'Called when idle' ); + + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionPreCommitOrIdle( + function ( IDatabase $db ) use ( &$called ) { + $called = true; + }, + __METHOD__ + ); + $this->assertFalse( $called, 'Not called when transaction is active' ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Called when transaction is committed' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionPreCommitOrIdle + * @covers Wikimedia\Rdbms\Database::runOnTransactionPreCommitCallbacks + */ + public function testTransactionPreCommitOrIdle_TRX() { + $db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'ping' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( 'unittest' ); + $db->setFlag( DBO_TRX ); + + $lbFactory = LBFactorySingle::newFromConnection( $db ); + // Ask for the connection so that LB sets internal state + // about this connection being the master connection + $lb = $lbFactory->getMainLB(); + $conn = $lb->openConnection( $lb->getWriterIndex() ); + $this->assertSame( $db, $conn, 'Same DB instance' ); + + $this->assertFalse( $lb->hasMasterChanges() ); + $this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' ); + $called = false; + $callback = function ( IDatabase $db ) use ( &$called ) { + $called = true; + }; + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertTrue( $called, 'Called when idle if DBO_TRX is set' ); + $called = false; + $lbFactory->commitMasterChanges(); + $this->assertFalse( $called ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertTrue( $called, 'Called when lb-transaction is committed' ); + + $called = false; + $lbFactory->beginMasterChanges( __METHOD__ ); + $db->onTransactionPreCommitOrIdle( $callback, __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is active' ); + + $lbFactory->rollbackMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called when lb-transaction is rolled back' ); + + $lbFactory->commitMasterChanges( __METHOD__ ); + $this->assertFalse( $called, 'Not called in next round commit' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::onTransactionResolution + * @covers Wikimedia\Rdbms\Database::runOnTransactionIdleCallbacks + */ + public function testTransactionResolution() { + $db = $this->db; + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function ( $trigger, IDatabase $db ) use ( &$called ) { + $called = true; + $db->setFlag( DBO_TRX ); + } ); + $db->commit( __METHOD__ ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->clearFlag( DBO_TRX ); + $db->begin( __METHOD__ ); + $called = false; + $db->onTransactionResolution( function ( $trigger, IDatabase $db ) use ( &$called ) { + $called = true; + $db->setFlag( DBO_TRX ); + } ); + $db->rollback( __METHOD__ ); + $this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' ); + $this->assertTrue( $called, 'Callback reached' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::setTransactionListener + */ + public function testTransactionListener() { + $db = $this->db; + + $db->setTransactionListener( 'ping', function () use ( $db, &$called ) { + $called = true; + } ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertTrue( $called, 'Callback still reached' ); + + $called = false; + $db->begin( __METHOD__ ); + $db->rollback( __METHOD__ ); + $this->assertTrue( $called, 'Callback reached' ); + + $db->setTransactionListener( 'ping', null ); + $called = false; + $db->begin( __METHOD__ ); + $db->commit( __METHOD__ ); + $this->assertFalse( $called, 'Callback not reached' ); + } + + /** + * Use this mock instead of DatabaseTestHelper for cases where + * DatabaseTestHelper is too inflexibile due to mocking too much + * or being too restrictive about fname matching (e.g. for tests + * that assert behaviour when the name is a mismatch, we need to + * catch the error here instead of there). + * + * @return Database + */ + private function getMockDB( $methods = [] ) { + static $abstractMethods = [ + 'fetchAffectedRowCount', + 'closeConnection', + 'dataSeek', + 'doQuery', + 'fetchObject', 'fetchRow', + 'fieldInfo', 'fieldName', + 'getSoftwareLink', 'getServerVersion', + 'getType', + 'indexInfo', + 'insertId', + 'lastError', 'lastErrno', + 'numFields', 'numRows', + 'open', + 'strencode', + 'tableExists' + ]; + $db = $this->getMockBuilder( Database::class ) + ->disableOriginalConstructor() + ->setMethods( array_values( array_unique( array_merge( + $abstractMethods, + $methods + ) ) ) ) + ->getMock(); + $wdb = TestingAccessWrapper::newFromObject( $db ); + $wdb->trxProfiler = new TransactionProfiler(); + $wdb->connLogger = new \Psr\Log\NullLogger(); + $wdb->queryLogger = new \Psr\Log\NullLogger(); + $wdb->currentDomain = DatabaseDomain::newUnspecified(); + return $db; + } + + /** + * @covers Wikimedia\Rdbms\Database::flushSnapshot + */ + public function testFlushSnapshot() { + $db = $this->getMockDB( [ 'isOpen' ] ); + $db->method( 'isOpen' )->willReturn( true ); + + $db->flushSnapshot( __METHOD__ ); // ok + $db->flushSnapshot( __METHOD__ ); // ok + + $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $db->query( 'SELECT 1', __METHOD__ ); + $this->assertTrue( (bool)$db->trxLevel(), "Transaction started." ); + $db->flushSnapshot( __METHOD__ ); // ok + $db->restoreFlags( $db::RESTORE_PRIOR ); + + $this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." ); + } + + /** + * @covers Wikimedia\Rdbms\Database::getScopedLockAndFlush + * @covers Wikimedia\Rdbms\Database::lock + * @covers Wikimedia\Rdbms\Database::unlock + * @covers Wikimedia\Rdbms\Database::lockIsFree + */ + public function testGetScopedLock() { + $db = $this->getMockDB( [ 'isOpen', 'getDBname' ] ); + $db->method( 'isOpen' )->willReturn( true ); + $db->method( 'getDBname' )->willReturn( 'unittest' ); + + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) ); + $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( 0, $db->trxLevel() ); + + $db->setFlag( DBO_TRX ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lock( 'x', __METHOD__ ) ); + $this->assertEquals( false, $db->lockIsFree( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->unlock( 'x', __METHOD__ ) ); + $this->assertEquals( true, $db->lockIsFree( 'x', __METHOD__ ) ); + $db->clearFlag( DBO_TRX ); + + // Pending writes with DBO_TRX + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) ); + $db->setFlag( DBO_TRX ); + $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock + try { + $lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // Pending writes without DBO_TRX + $db->clearFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ) ); + $db->begin( __METHOD__ ); + $db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock + try { + $lock = $db->getScopedLockAndFlush( 'meow2', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__ ); + // No pending writes, with DBO_TRX + $db->setFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'wuff', __METHOD__ ) ); + $db->query( "SELECT 1", __METHOD__ ); + $this->assertEquals( 1, $db->trxLevel() ); + $lock = $db->getScopedLockAndFlush( 'wuff', __METHOD__, 1 ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertFalse( $db->lockIsFree( 'wuff', __METHOD__ ), 'Lock already acquired' ); + $db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS ); + // No pending writes, without DBO_TRX + $db->clearFlag( DBO_TRX ); + $this->assertEquals( 0, $db->trxLevel() ); + $this->assertTrue( $db->lockIsFree( 'wuff2', __METHOD__ ) ); + $db->begin( __METHOD__ ); + try { + $lock = $db->getScopedLockAndFlush( 'wuff2', __METHOD__, 1 ); + $this->fail( "Exception not reached" ); + } catch ( DBUnexpectedError $e ) { + $this->assertEquals( 1, $db->trxLevel(), "Transaction not committed." ); + $this->assertFalse( $db->lockIsFree( 'wuff2', __METHOD__ ), 'Lock not acquired' ); + } + $db->rollback( __METHOD__ ); + } + + /** + * @covers Wikimedia\Rdbms\Database::getFlag + * @covers Wikimedia\Rdbms\Database::setFlag + * @covers Wikimedia\Rdbms\Database::restoreFlags + */ + public function testFlagSetting() { + $db = $this->db; + $origTrx = $db->getFlag( DBO_TRX ); + $origSsl = $db->getFlag( DBO_SSL ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + $this->assertEquals( !$origSsl, $db->getFlag( DBO_SSL ) ); + + $db->restoreFlags( $db::RESTORE_INITIAL ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + + $origTrx + ? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR ); + $origSsl + ? $db->clearFlag( DBO_SSL, $db::REMEMBER_PRIOR ) + : $db->setFlag( DBO_SSL, $db::REMEMBER_PRIOR ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) ); + + $db->restoreFlags(); + $this->assertEquals( $origSsl, $db->getFlag( DBO_SSL ) ); + $this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) ); + } + + /** + * @expectedException UnexpectedValueException + * @covers Wikimedia\Rdbms\Database::setFlag + */ + public function testDBOIgnoreSet() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $db->setFlag( Database::DBO_IGNORE ); + } + + /** + * @expectedException UnexpectedValueException + * @covers Wikimedia\Rdbms\Database::clearFlag + */ + public function testDBOIgnoreClear() { + $db = $this->getMockBuilder( DatabaseMysqli::class ) + ->disableOriginalConstructor() + ->setMethods( null ) + ->getMock(); + + $db->clearFlag( Database::DBO_IGNORE ); + } + + /** + * @covers Wikimedia\Rdbms\Database::tablePrefix + * @covers Wikimedia\Rdbms\Database::dbSchema + */ + public function testSchemaAndPrefixMutators() { + $ud = DatabaseDomain::newUnspecified(); + + $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); + + $old = $this->db->tablePrefix(); + $oldDomain = $this->db->getDomainId(); + $this->assertInternalType( 'string', $old, 'Prefix is string' ); + $this->assertSame( $old, $this->db->tablePrefix(), "Prefix unchanged" ); + $this->assertSame( $old, $this->db->tablePrefix( 'xxx_' ) ); + $this->assertSame( 'xxx_', $this->db->tablePrefix(), "Prefix set" ); + $this->db->tablePrefix( $old ); + $this->assertNotEquals( 'xxx_', $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + + $old = $this->db->dbSchema(); + $oldDomain = $this->db->getDomainId(); + $this->assertInternalType( 'string', $old, 'Schema is string' ); + $this->assertSame( $old, $this->db->dbSchema(), "Schema unchanged" ); + + $this->db->selectDB( 'y' ); + $this->assertSame( $old, $this->db->dbSchema( 'xxx' ) ); + $this->assertSame( 'xxx', $this->db->dbSchema(), "Schema set" ); + $this->db->dbSchema( $old ); + $this->assertNotEquals( 'xxx', $this->db->dbSchema() ); + $this->assertSame( "y", $this->db->getDomainId() ); + } + + /** + * @covers Wikimedia\Rdbms\Database::tablePrefix + * @covers Wikimedia\Rdbms\Database::dbSchema + * @expectedException DBUnexpectedError + */ + public function testSchemaWithNoDB() { + $ud = DatabaseDomain::newUnspecified(); + + $this->assertEquals( $ud->getId(), $this->db->getDomainID() ); + $this->assertSame( '', $this->db->dbSchema() ); + + $this->db->dbSchema( 'xxx' ); + } + + /** + * @covers Wikimedia\Rdbms\Database::selectDomain + */ + public function testSelectDomain() { + $oldDomain = $this->db->getDomainId(); + $oldDatabase = $this->db->getDBname(); + $oldSchema = $this->db->dbSchema(); + $oldPrefix = $this->db->tablePrefix(); + + $this->db->selectDomain( 'testselectdb-xxx_' ); + $this->assertSame( 'testselectdb', $this->db->getDBname() ); + $this->assertSame( '', $this->db->dbSchema() ); + $this->assertSame( 'xxx_', $this->db->tablePrefix() ); + + $this->db->selectDomain( $oldDomain ); + $this->assertSame( $oldDatabase, $this->db->getDBname() ); + $this->assertSame( $oldSchema, $this->db->dbSchema() ); + $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + + $this->db->selectDomain( 'testselectdb-schema-xxx_' ); + $this->assertSame( 'testselectdb', $this->db->getDBname() ); + $this->assertSame( 'schema', $this->db->dbSchema() ); + $this->assertSame( 'xxx_', $this->db->tablePrefix() ); + + $this->db->selectDomain( $oldDomain ); + $this->assertSame( $oldDatabase, $this->db->getDBname() ); + $this->assertSame( $oldSchema, $this->db->dbSchema() ); + $this->assertSame( $oldPrefix, $this->db->tablePrefix() ); + $this->assertSame( $oldDomain, $this->db->getDomainId() ); + } + +} diff --git a/tests/phpunit/includes/libs/services/ServiceContainerTest.php b/tests/phpunit/includes/libs/services/ServiceContainerTest.php new file mode 100644 index 0000000000..6e51883cfb --- /dev/null +++ b/tests/phpunit/includes/libs/services/ServiceContainerTest.php @@ -0,0 +1,497 @@ +newServiceContainer(); + $names = $services->getServiceNames(); + + $this->assertInternalType( 'array', $names ); + $this->assertEmpty( $names ); + + $name = 'TestService92834576'; + $services->defineService( $name, function () { + return null; + } ); + + $names = $services->getServiceNames(); + $this->assertContains( $name, $names ); + } + + public function testHasService() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + $this->assertFalse( $services->hasService( $name ) ); + + $services->defineService( $name, function () { + return null; + } ); + + $this->assertTrue( $services->hasService( $name ) ); + } + + public function testGetService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + $count = 0; + + $services->defineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService, &$count ) { + $count++; + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( $extra, 'Foo' ); + return $theService; + } + ); + + $this->assertSame( $theService, $services->getService( $name ) ); + + $services->getService( $name ); + $this->assertSame( 1, $count, 'instantiator should be called exactly once!' ); + } + + public function testGetService_fail_unknown() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->getService( $name ); + } + + public function testPeekService() { + $services = $this->newServiceContainer(); + + $services->defineService( + 'Foo', + function () { + return new stdClass(); + } + ); + + $services->defineService( + 'Bar', + function () { + return new stdClass(); + } + ); + + // trigger instantiation of Foo + $services->getService( 'Foo' ); + + $this->assertInternalType( + 'object', + $services->peekService( 'Foo' ), + 'Peek should return the service object if it had been accessed before.' + ); + + $this->assertNull( + $services->peekService( 'Bar' ), + 'Peek should return null if the service was never accessed.' + ); + } + + public function testPeekService_fail_unknown() { + $services = $this->newServiceContainer(); + + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->peekService( $name ); + } + + public function testDefineService() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function ( $actualLocator ) use ( $services, $theService ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + return $theService; + } ); + + $this->assertTrue( $services->hasService( $name ) ); + $this->assertSame( $theService, $services->getService( $name ) ); + } + + public function testDefineService_fail_duplicate() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + + $this->setExpectedException( Wikimedia\Services\ServiceAlreadyDefinedException::class ); + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testApplyWiring() { + $services = $this->newServiceContainer(); + + $wiring = [ + 'Foo' => function () { + return 'Foo!'; + }, + 'Bar' => function () { + return 'Bar!'; + }, + ]; + + $services->applyWiring( $wiring ); + + $this->assertSame( 'Foo!', $services->getService( 'Foo' ) ); + $this->assertSame( 'Bar!', $services->getService( 'Bar' ) ); + } + + public function testImportWiring() { + $services = $this->newServiceContainer(); + + $wiring = [ + 'Foo' => function () { + return 'Foo!'; + }, + 'Bar' => function () { + return 'Bar!'; + }, + 'Car' => function () { + return 'FUBAR!'; + }, + ]; + + $services->applyWiring( $wiring ); + + $services->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+X'; + } ); + + $services->addServiceManipulator( 'Car', function ( $service ) { + return $service . '+X'; + } ); + + $newServices = $this->newServiceContainer(); + + // create a service with manipulator + $newServices->defineService( 'Foo', function () { + return 'Foo!'; + } ); + + $newServices->addServiceManipulator( 'Foo', function ( $service ) { + return $service . '+Y'; + } ); + + // create a service before importing, so we can later check that + // existing service instances survive importWiring() + $newServices->defineService( 'Car', function () { + return 'Car!'; + } ); + + // force instantiation + $newServices->getService( 'Car' ); + + // Define another service, so we can later check that extra wiring + // is not lost. + $newServices->defineService( 'Xar', function () { + return 'Xar!'; + } ); + + // import wiring, but skip `Bar` + $newServices->importWiring( $services, [ 'Bar' ] ); + + $this->assertNotContains( 'Bar', $newServices->getServiceNames(), 'Skip `Bar` service' ); + $this->assertSame( 'Foo!+Y+X', $newServices->getService( 'Foo' ) ); + + // import all wiring, but preserve existing service instance + $newServices->importWiring( $services ); + + $this->assertContains( 'Bar', $newServices->getServiceNames(), 'Import all services' ); + $this->assertSame( 'Bar!', $newServices->getService( 'Bar' ) ); + $this->assertSame( 'Car!', $newServices->getService( 'Car' ), 'Use existing service instance' ); + $this->assertSame( 'Xar!', $newServices->getService( 'Xar' ), 'Predefined services are kept' ); + } + + public function testLoadWiringFiles() { + $services = $this->newServiceContainer(); + + $wiringFiles = [ + __DIR__ . '/TestWiring1.php', + __DIR__ . '/TestWiring2.php', + ]; + + $services->loadWiringFiles( $wiringFiles ); + + $this->assertSame( 'Foo!', $services->getService( 'Foo' ) ); + $this->assertSame( 'Bar!', $services->getService( 'Bar' ) ); + } + + public function testLoadWiringFiles_fail_duplicate() { + $services = $this->newServiceContainer(); + + $wiringFiles = [ + __DIR__ . '/TestWiring1.php', + __DIR__ . '/./TestWiring1.php', + ]; + + // loading the same file twice should fail, because + $this->setExpectedException( Wikimedia\Services\ServiceAlreadyDefinedException::class ); + + $services->loadWiringFiles( $wiringFiles ); + } + + public function testRedefineService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + PHPUnit_Framework_Assert::fail( + 'The original instantiator function should not get called' + ); + } ); + + // redefine before instantiation + $services->redefineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService1 ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService1; + } + ); + + // force instantiation, check result + $this->assertSame( $theService1, $services->getService( $name ) ); + } + + public function testRedefineService_disabled() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + return 'Foo'; + } ); + + // disable the service. we should be able to redefine it anyway. + $services->disableService( $name ); + + $services->redefineService( $name, function () use ( $theService1 ) { + return $theService1; + } ); + + // force instantiation, check result + $this->assertSame( $theService1, $services->getService( $name ) ); + } + + public function testRedefineService_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testRedefineService_fail_in_use() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () { + return 'Foo'; + } ); + + // create the service, so it can no longer be redefined + $services->getService( $name ); + + $this->setExpectedException( Wikimedia\Services\CannotReplaceActiveServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testAddServiceManipulator() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService1 = new stdClass(); + $theService2 = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( + $name, + function ( $actualLocator, $extra ) use ( $services, $theService1 ) { + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService1; + } + ); + + $services->addServiceManipulator( + $name, + function ( + $theService, $actualLocator, $extra + ) use ( + $services, $theService1, $theService2 + ) { + PHPUnit_Framework_Assert::assertSame( $theService1, $theService ); + PHPUnit_Framework_Assert::assertSame( $services, $actualLocator ); + PHPUnit_Framework_Assert::assertSame( 'Foo', $extra ); + return $theService2; + } + ); + + // force instantiation, check result + $this->assertSame( $theService2, $services->getService( $name ) ); + } + + public function testAddServiceManipulator_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->addServiceManipulator( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testAddServiceManipulator_fail_in_use() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $services->defineService( $name, function () use ( $theService ) { + return $theService; + } ); + + // create the service, so it can no longer be redefined + $services->getService( $name ); + + $this->setExpectedException( Wikimedia\Services\CannotReplaceActiveServiceException::class ); + + $services->addServiceManipulator( $name, function () { + return 'Foo'; + } ); + } + + public function testDisableService() { + $services = $this->newServiceContainer( [ 'Foo' ] ); + + $destructible = $this->getMockBuilder( Wikimedia\Services\DestructibleService::class ) + ->getMock(); + $destructible->expects( $this->once() ) + ->method( 'destroy' ); + + $services->defineService( 'Foo', function () use ( $destructible ) { + return $destructible; + } ); + $services->defineService( 'Bar', function () { + return new stdClass(); + } ); + $services->defineService( 'Qux', function () { + return new stdClass(); + } ); + + // instantiate Foo and Bar services + $services->getService( 'Foo' ); + $services->getService( 'Bar' ); + + // disable service, should call destroy() once. + $services->disableService( 'Foo' ); + + // disabled service should still be listed + $this->assertContains( 'Foo', $services->getServiceNames() ); + + // getting other services should still work + $services->getService( 'Bar' ); + + // disable non-destructible service, and not-yet-instantiated service + $services->disableService( 'Bar' ); + $services->disableService( 'Qux' ); + + $this->assertNull( $services->peekService( 'Bar' ) ); + $this->assertNull( $services->peekService( 'Qux' ) ); + + // disabled service should still be listed + $this->assertContains( 'Bar', $services->getServiceNames() ); + $this->assertContains( 'Qux', $services->getServiceNames() ); + + $this->setExpectedException( Wikimedia\Services\ServiceDisabledException::class ); + $services->getService( 'Qux' ); + } + + public function testDisableService_fail_undefined() { + $services = $this->newServiceContainer(); + + $theService = new stdClass(); + $name = 'TestService92834576'; + + $this->setExpectedException( Wikimedia\Services\NoSuchServiceException::class ); + + $services->redefineService( $name, function () use ( $theService ) { + return $theService; + } ); + } + + public function testDestroy() { + $services = $this->newServiceContainer(); + + $destructible = $this->getMockBuilder( Wikimedia\Services\DestructibleService::class ) + ->getMock(); + $destructible->expects( $this->once() ) + ->method( 'destroy' ); + + $services->defineService( 'Foo', function () use ( $destructible ) { + return $destructible; + } ); + + $services->defineService( 'Bar', function () { + return new stdClass(); + } ); + + // create the service + $services->getService( 'Foo' ); + + // destroy the container + $services->destroy(); + + $this->setExpectedException( Wikimedia\Services\ContainerDisabledException::class ); + $services->getService( 'Bar' ); + } + +} diff --git a/tests/phpunit/includes/libs/services/TestWiring1.php b/tests/phpunit/includes/libs/services/TestWiring1.php new file mode 100644 index 0000000000..b6ff4eb3b4 --- /dev/null +++ b/tests/phpunit/includes/libs/services/TestWiring1.php @@ -0,0 +1,10 @@ + function () { + return 'Foo!'; + }, +]; diff --git a/tests/phpunit/includes/libs/services/TestWiring2.php b/tests/phpunit/includes/libs/services/TestWiring2.php new file mode 100644 index 0000000000..dfff64f048 --- /dev/null +++ b/tests/phpunit/includes/libs/services/TestWiring2.php @@ -0,0 +1,10 @@ + function () { + return 'Bar!'; + }, +]; diff --git a/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php b/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php new file mode 100644 index 0000000000..46e23e36db --- /dev/null +++ b/tests/phpunit/includes/libs/stats/PrefixingStatsdDataFactoryProxyTest.php @@ -0,0 +1,58 @@ +getMock( + \Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface::class + ); + $innerFactory->expects( $this->once() ) + ->method( $method ) + ->with( 'testprefix.metricname' ); + + $proxy = new PrefixingStatsdDataFactoryProxy( $innerFactory, 'testprefix' ); + // 1,2,3,4 simply makes sure we provide enough parameters, without caring what they are + $proxy->$method( 'metricname', 1, 2, 3, 4 ); + } + + /** + * @dataProvider provideMethodNames + */ + public function testPrefixIsTrimmed( $method ) { + /** @var StatsdDataFactoryInterface|PHPUnit_Framework_MockObject_MockObject $innerFactory */ + $innerFactory = $this->getMock( + \Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface::class + ); + $innerFactory->expects( $this->once() ) + ->method( $method ) + ->with( 'testprefix.metricname' ); + + $proxy = new PrefixingStatsdDataFactoryProxy( $innerFactory, 'testprefix...' ); + // 1,2,3,4 simply makes sure we provide enough parameters, without caring what they are + $proxy->$method( 'metricname', 1, 2, 3, 4 ); + } + +} diff --git a/tests/phpunit/includes/media/GIFMetadataExtractorTest.php b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php new file mode 100644 index 0000000000..278b441bbd --- /dev/null +++ b/tests/phpunit/includes/media/GIFMetadataExtractorTest.php @@ -0,0 +1,110 @@ +mediaPath = __DIR__ . '/../../data/media/'; + } + + /** + * Put in a file, and see if the metadata coming out is as expected. + * @param string $filename + * @param array $expected The extracted metadata. + * @dataProvider provideGetMetadata + * @covers GIFMetadataExtractor::getMetadata + */ + public function testGetMetadata( $filename, $expected ) { + $actual = GIFMetadataExtractor::getMetadata( $this->mediaPath . $filename ); + $this->assertEquals( $expected, $actual ); + } + + public static function provideGetMetadata() { + $xmpNugget = << + + + + + The interwebs + + + + Bawolff + + + A file to test GIF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +EOF; + $xmpNugget = str_replace( "\r", '', $xmpNugget ); // Windows compat + + return [ + [ + 'nonanimated.gif', + [ + 'comment' => [ 'GIF test file ⁕ Created with GIMP' ], + 'duration' => 0.1, + 'frameCount' => 1, + 'looped' => false, + 'xmp' => '', + ] + ], + [ + 'animated.gif', + [ + 'comment' => [ 'GIF test file . Created with GIMP' ], + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'xmp' => '', + ] + ], + + [ + 'animated-xmp.gif', + [ + 'xmp' => $xmpNugget, + 'duration' => 2.4, + 'frameCount' => 4, + 'looped' => true, + 'comment' => [ 'GIƒ·test·file' ], + ] + ], + ]; + } +} diff --git a/tests/phpunit/includes/media/IPTCTest.php b/tests/phpunit/includes/media/IPTCTest.php new file mode 100644 index 0000000000..4b3ba0755c --- /dev/null +++ b/tests/phpunit/includes/media/IPTCTest.php @@ -0,0 +1,85 @@ +assertEquals( 'UTF-8', $res ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharset88591() { + // basically IPTC for keyword with value of 0xBC which is 1/4 in iso-8859-1 + // This data doesn't specify a charset. We're supposed to guess + // (which basically means utf-8 if valid, windows 1252 (iso 8859-1) if not) + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x06\x1c\x02\x19\x00\x01\xBC"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharset88591b() { + /* This one contains a sequence that's valid iso 8859-1 but not valid utf8 */ + /* \xC3 = Ã, \xB8 = ¸ */ + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x09\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ 'ÃÃø' ], $res['Keywords'] ); + } + + /** + * Same as testIPTCParseNoCharset88591b, but forcing the charset to utf-8. + * What should happen is the first "\xC3\xC3" should be dropped as invalid, + * leaving \xC3\xB8, which is ø + * @covers IPTC::parse + */ + public function testIPTCParseForcedUTFButInvalid() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x11\x1c\x02\x19\x00\x04\xC3\xC3\xC3\xB8" + . "\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ 'ø' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseNoCharsetUTF8() { + $iptcData = "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x07\x1c\x02\x19\x00\x02¼"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } + + /** + * Testing something that has 2 values for keyword + * @covers IPTC::parse + */ + public function testIPTCParseMulti() { + $iptcData = /* identifier */ "Photoshop 3.0\08BIM\4\4" + /* length */ . "\0\0\0\0\0\x0D" + . "\x1c\x02\x19" . "\x00\x01" . "\xBC" + . "\x1c\x02\x19" . "\x00\x02" . "\xBC\xBD"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼', '¼½' ], $res['Keywords'] ); + } + + /** + * @covers IPTC::parse + */ + public function testIPTCParseUTF8() { + // This has the magic "\x1c\x01\x5A\x00\x03\x1B\x25\x47" which marks content as UTF8. + $iptcData = + "Photoshop 3.0\08BIM\4\4\0\0\0\0\0\x0F\x1c\x02\x19\x00\x02¼\x1c\x01\x5A\x00\x03\x1B\x25\x47"; + $res = IPTC::parse( $iptcData ); + $this->assertEquals( [ '¼' ], $res['Keywords'] ); + } +} diff --git a/tests/phpunit/includes/media/JpegMetadataExtractorTest.php b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php new file mode 100644 index 0000000000..c943cef906 --- /dev/null +++ b/tests/phpunit/includes/media/JpegMetadataExtractorTest.php @@ -0,0 +1,128 @@ +filePath = __DIR__ . '/../../data/media/'; + } + + /** + * We also use this test to test padding bytes don't + * screw stuff up + * + * @param string $file Filename + * + * @dataProvider provideUtf8Comment + */ + public function testUtf8Comment( $file ) { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . $file ); + $this->assertEquals( [ 'UTF-8 JPEG Comment — ¼' ], $res['COM'] ); + } + + public static function provideUtf8Comment() { + return [ + [ 'jpeg-comment-utf.jpg' ], + [ 'jpeg-padding-even.jpg' ], + [ 'jpeg-padding-odd.jpg' ], + ]; + } + + /** The file is iso-8859-1, but it should get auto converted */ + public function testIso88591Comment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-iso8859-1.jpg' ); + $this->assertEquals( [ 'ISO-8859-1 JPEG Comment - ¼' ], $res['COM'] ); + } + + /** Comment values that are non-textual (random binary junk) should not be shown. + * The example test file has a comment with a 0x5 byte in it which is a control character + * and considered binary junk for our purposes. + */ + public function testBinaryCommentStripped() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-binary.jpg' ); + $this->assertEmpty( $res['COM'] ); + } + + /* Very rarely a file can have multiple comments. + * Order of comments is based on order inside the file. + */ + public function testMultipleComment() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-comment-multiple.jpg' ); + $this->assertEquals( [ 'foo', 'bar' ], $res['COM'] ); + } + + public function testXMPExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testPSIRExtraction() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $expected = '50686f746f73686f7020332e30003842494d04040000000' + . '000181c02190004746573741c02190003666f6f1c020000020004'; + $this->assertEquals( $expected, bin2hex( $res['PSIR'][0] ) ); + } + + public function testXMPExtractionAltAppId() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-alt.jpg' ); + $expected = file_get_contents( $this->filePath . 'jpeg-xmp-psir.xmp' ); + $this->assertEquals( $expected, $res['XMP'] ); + } + + public function testIPTCHashComparisionNoHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-xmp-psir.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-no-hash', $res ); + } + + public function testIPTCHashComparisionBadHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-bad-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-bad-hash', $res ); + } + + public function testIPTCHashComparisionGoodHash() { + $segments = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-iptc-good-hash.jpg' ); + $res = JpegMetadataExtractor::doPSIR( $segments['PSIR'][0] ); + + $this->assertEquals( 'iptc-good-hash', $res ); + } + + public function testExifByteOrder() { + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'exif-user-comment.jpg' ); + $expected = 'BE'; + $this->assertEquals( $expected, $res['byteOrder'] ); + } + + public function testInfiniteRead() { + // test file truncated right after a segment, which previously + // caused an infinite loop looking for the next segment byte. + // Should get past infinite loop and throw in wfUnpack() + $this->setExpectedException( 'MWException' ); + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop1.jpg' ); + } + + public function testInfiniteRead2() { + // test file truncated after a segment's marker and size, which + // would cause a seek past end of file. Seek past end of file + // doesn't actually fail, but prevents further reading and was + // devolving into the previous case (testInfiniteRead). + $this->setExpectedException( 'MWException' ); + $res = JpegMetadataExtractor::segmentSplitter( $this->filePath . 'jpeg-segment-loop2.jpg' ); + } +} diff --git a/tests/phpunit/includes/media/MediaHandlerTest.php b/tests/phpunit/includes/media/MediaHandlerTest.php new file mode 100644 index 0000000000..7a052f6035 --- /dev/null +++ b/tests/phpunit/includes/media/MediaHandlerTest.php @@ -0,0 +1,68 @@ +assertEquals( $expected, + $result, + "($width, $height, $max) wanted: {$expected}x$y, got: {z$result}x$y2" ); + } + + public static function provideTestFitBoxWidth() { + return array_merge( + static::generateTestFitBoxWidthData( 50, 50, [ + 50 => 50, + 17 => 17, + 18 => 18 ] + ), + static::generateTestFitBoxWidthData( 366, 300, [ + 50 => 61, + 17 => 21, + 18 => 22 ] + ), + static::generateTestFitBoxWidthData( 300, 366, [ + 50 => 41, + 17 => 14, + 18 => 15 ] + ), + static::generateTestFitBoxWidthData( 100, 400, [ + 50 => 12, + 17 => 4, + 18 => 4 ] + ) + ); + } + + /** + * Generate single test cases by combining the dimensions and tests contents + * + * It creates: + * [$width, $height, $max, $expected], + * [$width, $height, $max2, $expected2], ... + * out of parameters: + * $width, $height, { $max => $expected, $max2 => $expected2, ... } + * + * @param int $width + * @param int $height + * @param array $tests associative array of $max => $expected values + * @return array + */ + private static function generateTestFitBoxWidthData( $width, $height, $tests ) { + $result = []; + foreach ( $tests as $max => $expected ) { + $result[] = [ $width, $height, $max, $expected ]; + } + return $result; + } +} diff --git a/tests/phpunit/includes/media/SVGMetadataExtractorTest.php b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php new file mode 100644 index 0000000000..6b94d0ae6c --- /dev/null +++ b/tests/phpunit/includes/media/SVGMetadataExtractorTest.php @@ -0,0 +1,201 @@ +assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgFilesWithXMLMetadata + */ + public function testGetXMLMetadata( $infile, $expected ) { + $r = new XMLReader(); + $this->assertMetadata( $infile, $expected ); + } + + /** + * @dataProvider provideSvgUnits + */ + public function testScaleSVGUnit( $inUnit, $expected ) { + $this->assertEquals( + $expected, + SVGReader::scaleSVGUnit( $inUnit ), + 'SVG unit conversion and scaling failure' + ); + } + + function assertMetadata( $infile, $expected ) { + try { + $data = SVGMetadataExtractor::getMetadata( $infile ); + $this->assertEquals( $expected, $data, 'SVG metadata extraction test' ); + } catch ( MWException $e ) { + if ( $expected === false ) { + $this->assertTrue( true, 'SVG metadata extracted test (expected failure)' ); + } else { + throw $e; + } + } + } + + public static function provideSvgFiles() { + $base = __DIR__ . '/../../data/media'; + + return [ + [ + "$base/Wikimedia-logo.svg", + [ + 'width' => 1024, + 'height' => 1024, + 'originalWidth' => '1024', + 'originalHeight' => '1024', + 'translations' => [], + ] + ], + [ + "$base/QA_icon.svg", + [ + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60', + 'originalHeight' => '60', + 'translations' => [], + ] + ], + [ + "$base/Gtk-media-play-ltr.svg", + [ + 'width' => 60, + 'height' => 60, + 'originalWidth' => '60.0000000', + 'originalHeight' => '60.0000000', + 'translations' => [], + ] + ], + [ + "$base/Toll_Texas_1.svg", + // This file triggered T33719, needs entity expansion in the xmlns checks + [ + 'width' => 385, + 'height' => 385, + 'originalWidth' => '385', + 'originalHeight' => '385.0004883', + 'translations' => [], + ] + ], + [ + "$base/Tux.svg", + [ + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'title' => 'Tux', + 'translations' => [], + 'description' => 'For more information see: http://commons.wikimedia.org/wiki/Image:Tux.svg', + ] + ], + [ + "$base/Speech_bubbles.svg", + [ + 'width' => 627, + 'height' => 461, + 'originalWidth' => '17.7cm', + 'originalHeight' => '13cm', + 'translations' => [ + 'de' => SVGReader::LANG_FULL_MATCH, + 'fr' => SVGReader::LANG_FULL_MATCH, + 'nl' => SVGReader::LANG_FULL_MATCH, + 'tlh-ca' => SVGReader::LANG_FULL_MATCH, + 'tlh' => SVGReader::LANG_PREFIX_MATCH + ], + ] + ], + [ + "$base/Soccer_ball_animated.svg", + [ + 'width' => 150, + 'height' => 150, + 'originalWidth' => '150', + 'originalHeight' => '150', + 'animated' => true, + 'translations' => [] + ], + ], + [ + "$base/comma_separated_viewbox.svg", + [ + 'width' => 512, + 'height' => 594, + 'originalWidth' => '100%', + 'originalHeight' => '100%', + 'translations' => [] + ], + ], + ]; + } + + public static function provideSvgFilesWithXMLMetadata() { + $base = __DIR__ . '/../../data/media'; + // phpcs:disable Generic.Files.LineLength + $metadata = ' + + image/svg+xml + + + '; + // phpcs:enable + + $metadata = str_replace( "\r", '', $metadata ); // Windows compat + return [ + [ + "$base/US_states_by_total_state_tax_revenue.svg", + [ + 'height' => 593, + 'metadata' => $metadata, + 'width' => 959, + 'originalWidth' => '958.69', + 'originalHeight' => '592.78998', + 'translations' => [], + ] + ], + ]; + } + + public static function provideSvgUnits() { + return [ + [ '1' , 1 ], + [ '1.1' , 1.1 ], + [ '0.1' , 0.1 ], + [ '.1' , 0.1 ], + [ '1e2' , 100 ], + [ '1E2' , 100 ], + [ '+1' , 1 ], + [ '-1' , -1 ], + [ '-1.1' , -1.1 ], + [ '1e+2' , 100 ], + [ '1e-2' , 0.01 ], + [ '10px' , 10 ], + [ '10pt' , 10 * 1.25 ], + [ '10pc' , 10 * 15 ], + [ '10mm' , 10 * 3.543307 ], + [ '10cm' , 10 * 35.43307 ], + [ '10in' , 10 * 90 ], + [ '10em' , 10 * 16 ], + [ '10ex' , 10 * 12 ], + [ '10%' , 51.2 ], + [ '10 px' , 10 ], + // Invalid values + [ '1e1.1', 10 ], + [ '10bp', 10 ], + [ 'p10', null ], + ]; + } +} diff --git a/tests/phpunit/includes/media/WebPHandlerTest.php b/tests/phpunit/includes/media/WebPHandlerTest.php new file mode 100644 index 0000000000..ac0ad98edd --- /dev/null +++ b/tests/phpunit/includes/media/WebPHandlerTest.php @@ -0,0 +1,151 @@ +tempFileName = tempnam( wfTempDir(), 'WEBP' ); + } + + public function tearDown() { + parent::tearDown(); + unlink( $this->tempFileName ); + } + + /** + * @dataProvider provideTestExtractMetaData + */ + public function testExtractMetaData( $header, $expectedResult ) { + // Put header into file + file_put_contents( $this->tempFileName, $header ); + + $this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $this->tempFileName ) ); + } + + public function provideTestExtractMetaData() { + // phpcs:disable Generic.Files.LineLength + return [ + // Files from https://developers.google.com/speed/webp/gallery2 + [ "\x52\x49\x46\x46\x90\x68\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x83\x68\x01\x00\x2F\x8F\x01\x4B\x10\x8D\x38\x6C\xDB\x46\x92\xE0\xE0\x82\x7B\x6C", + [ 'compression' => 'lossless', 'width' => 400, 'height' => 301 ] ], + [ "\x52\x49\x46\x46\x64\x5B\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x8F\x01\x00\x2C\x01\x00\x41\x4C\x50\x48\xE5\x0E", + [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 400, 'height' => 301 ] ], + [ "\x52\x49\x46\x46\xA8\x72\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x9B\x72\x00\x00\x2F\x81\x81\x62\x10\x8D\x40\x8C\x24\x39\x6E\x73\x73\x38\x01\x96", + [ 'compression' => 'lossless', 'width' => 386, 'height' => 395 ] ], + [ "\x52\x49\x46\x46\xE0\x42\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x81\x01\x00\x8A\x01\x00\x41\x4C\x50\x48\x56\x10", + [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 386, 'height' => 395 ] ], + [ "\x52\x49\x46\x46\x70\x61\x02\x00\x57\x45\x42\x50\x56\x50\x38\x4C\x63\x61\x02\x00\x2F\x1F\xC3\x95\x10\x8D\xC8\x72\xDB\xC8\x92\x24\xD8\x91\xD9\x91", + [ 'compression' => 'lossless', 'width' => 800, 'height' => 600 ] ], + [ "\x52\x49\x46\x46\x1C\x1D\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x1F\x03\x00\x57\x02\x00\x41\x4C\x50\x48\x25\x8B", + [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 800, 'height' => 600 ] ], + [ "\x52\x49\x46\x46\xFA\xC5\x00\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xEE\xC5\x00\x00\x2F\xA4\x81\x28\x10\x8D\x40\x68\x24\xC9\x91\xA4\xAE\xF3\x97\x75", + [ 'compression' => 'lossless', 'width' => 421, 'height' => 163 ] ], + [ "\x52\x49\x46\x46\xF6\x5D\x00\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\xA4\x01\x00\xA2\x00\x00\x41\x4C\x50\x48\x38\x1A", + [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 421, 'height' => 163 ] ], + [ "\x52\x49\x46\x46\xC4\x96\x01\x00\x57\x45\x42\x50\x56\x50\x38\x4C\xB8\x96\x01\x00\x2F\x2B\xC1\x4A\x10\x11\x87\x6D\xDB\x48\x12\xFC\x60\xB0\x83\x24", + [ 'compression' => 'lossless', 'width' => 300, 'height' => 300 ] ], + [ "\x52\x49\x46\x46\x0A\x11\x01\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x10\x00\x00\x00\x2B\x01\x00\x2B\x01\x00\x41\x4C\x50\x48\x67\x6E", + [ 'compression' => 'unknown', 'animated' => false, 'transparency' => true, 'width' => 300, 'height' => 300 ] ], + + // Lossy files from https://developers.google.com/speed/webp/gallery1 + [ "\x52\x49\x46\x46\x68\x76\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\x5C\x76\x00\x00\xD2\xBE\x01\x9D\x01\x2A\x26\x02\x70\x01\x3E\xD5\x4E\x97\x43\xA2", + [ 'compression' => 'lossy', 'width' => 550, 'height' => 368 ] ], + [ "\x52\x49\x46\x46\xB0\xEC\x00\x00\x57\x45\x42\x50\x56\x50\x38\x20\xA4\xEC\x00\x00\xB2\x4B\x02\x9D\x01\x2A\x26\x02\x94\x01\x3E\xD1\x50\x96\x46\x26", + [ 'compression' => 'lossy', 'width' => 550, 'height' => 404 ] ], + [ "\x52\x49\x46\x46\x7A\x19\x03\x00\x57\x45\x42\x50\x56\x50\x38\x20\x6E\x19\x03\x00\xB2\xF8\x09\x9D\x01\x2A\x00\x05\xD0\x02\x3E\xAD\x46\x99\x4A\xA5", + [ 'compression' => 'lossy', 'width' => 1280, 'height' => 720 ] ], + [ "\x52\x49\x46\x46\x44\xB3\x02\x00\x57\x45\x42\x50\x56\x50\x38\x20\x38\xB3\x02\x00\x52\x57\x06\x9D\x01\x2A\x00\x04\x04\x03\x3E\xA5\x44\x96\x49\x26", + [ 'compression' => 'lossy', 'width' => 1024, 'height' => 772 ] ], + [ "\x52\x49\x46\x46\x02\x43\x01\x00\x57\x45\x42\x50\x56\x50\x38\x20\xF6\x42\x01\x00\x12\xC0\x05\x9D\x01\x2A\x00\x04\xF0\x02\x3E\x79\x34\x93\x47\xA4", + [ 'compression' => 'lossy', 'width' => 1024, 'height' => 752 ] ], + + // Animated file from https://groups.google.com/a/chromium.org/d/topic/blink-dev/Y8tRC4mdQz8/discussion + [ "\x52\x49\x46\x46\xD0\x0B\x02\x00\x57\x45\x42\x50\x56\x50\x38\x58\x0A\x00\x00\x00\x12\x00\x00\x00\x3F\x01\x00\x3F\x01\x00\x41\x4E", + [ 'compression' => 'unknown', 'animated' => true, 'transparency' => true, 'width' => 320, 'height' => 320 ] ], + + // Error cases + [ '', false ], + [ ' ', false ], + [ 'RIFF ', false ], + [ 'RIFF1234WEBP ', false ], + [ 'RIFF1234WEBPVP8 ', false ], + [ 'RIFF1234WEBPVP8L ', false ], + ]; + // phpcs:enable + } + + /** + * @dataProvider provideTestWithFileExtractMetaData + */ + public function testWithFileExtractMetaData( $filename, $expectedResult ) { + $this->assertEquals( $expectedResult, WebPHandler::extractMetadata( $filename ) ); + } + + public function provideTestWithFileExtractMetaData() { + return [ + [ __DIR__ . '/../../data/media/2_webp_ll.webp', + [ + 'compression' => 'lossless', + 'width' => 386, + 'height' => 395 + ] + ], + [ __DIR__ . '/../../data/media/2_webp_a.webp', + [ + 'compression' => 'lossy', + 'animated' => false, + 'transparency' => true, + 'width' => 386, + 'height' => 395 + ] + ], + ]; + } + + /** + * @dataProvider provideTestGetImageSize + */ + public function testGetImageSize( $path, $expectedResult ) { + $handler = new WebPHandler(); + $this->assertEquals( $expectedResult, $handler->getImageSize( null, $path ) ); + } + + public function provideTestGetImageSize() { + return [ + // Public domain files from https://developers.google.com/speed/webp/gallery2 + [ __DIR__ . '/../../data/media/2_webp_a.webp', [ 386, 395 ] ], + [ __DIR__ . '/../../data/media/2_webp_ll.webp', [ 386, 395 ] ], + [ __DIR__ . '/../../data/media/webp_animated.webp', [ 300, 225 ] ], + + // Error cases + [ __FILE__, false ], + ]; + } + + /** + * Tests the WebP MIME detection. This should really be a separate test, but sticking it + * here for now. + * + * @dataProvider provideTestGetMimeType + */ + public function testGuessMimeType( $path ) { + $mime = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); + $this->assertEquals( 'image/webp', $mime->guessMimeType( $path, false ) ); + } + + public function provideTestGetMimeType() { + return [ + // Public domain files from https://developers.google.com/speed/webp/gallery2 + [ __DIR__ . '/../../data/media/2_webp_a.webp' ], + [ __DIR__ . '/../../data/media/2_webp_ll.webp' ], + [ __DIR__ . '/../../data/media/webp_animated.webp' ], + ]; + } +} + +/* Python code to extract a header and convert to PHP format: + * print '"%s"' % ''.implode( '\\x%02X' % ord(c) for c in urllib.urlopen(url).read(36) ) + */ diff --git a/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php b/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php new file mode 100644 index 0000000000..45971daced --- /dev/null +++ b/tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php @@ -0,0 +1,107 @@ +cache = new MemcachedPhpBagOStuff( [ 'keyspace' => 'test', 'servers' => [] ] ); + } + + /** + * @covers MemcachedBagOStuff::makeKey + */ + public function testKeyNormalization() { + $this->assertEquals( + 'test:vanilla', + $this->cache->makeKey( 'vanilla' ) + ); + + $this->assertEquals( + 'test:punctuation_marks_are_ok:!@$^&*()', + $this->cache->makeKey( 'punctuation_marks_are_ok', '!@$^&*()' ) + ); + + $this->assertEquals( + 'test:but_spaces:hashes%23:and%0Anewlines:are_not', + $this->cache->makeKey( 'but spaces', 'hashes#', "and\nnewlines", 'are_not' ) + ); + + $this->assertEquals( + 'test:this:key:contains:%F0%9D%95%9E%F0%9D%95%A6%F0%9D%95%9D%F0%9D%95%A5%F0%9' . + 'D%95%9A%F0%9D%95%93%F0%9D%95%AA%F0%9D%95%A5%F0%9D%95%96:characters', + $this->cache->makeKey( 'this', 'key', 'contains', '𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖', 'characters' ) + ); + + $this->assertEquals( + 'test:this:key:contains:#c118f92685a635cb843039de50014c9c', + $this->cache->makeKey( 'this', 'key', 'contains', '𝕥𝕠𝕠 𝕞𝕒𝕟𝕪 𝕞𝕦𝕝𝕥𝕚𝕓𝕪𝕥𝕖 𝕔𝕙𝕒𝕣𝕒𝕔𝕥𝕖𝕣𝕤' ) + ); + + $this->assertEquals( + 'test:BagOStuff-long-key:##dc89dcb43b28614da27660240af478b5', + $this->cache->makeKey( '𝕖𝕧𝕖𝕟', '𝕚𝕗', '𝕨𝕖', '𝕄𝔻𝟝', '𝕖𝕒𝕔𝕙', + '𝕒𝕣𝕘𝕦𝕞𝕖𝕟𝕥', '𝕥𝕙𝕚𝕤', '𝕜𝕖𝕪', '𝕨𝕠𝕦𝕝𝕕', '𝕤𝕥𝕚𝕝𝕝', '𝕓𝕖', '𝕥𝕠𝕠', '𝕝𝕠𝕟𝕘' ) + ); + + $this->assertEquals( + 'test:%23%235820ad1d105aa4dc698585c39df73e19', + $this->cache->makeKey( '##5820ad1d105aa4dc698585c39df73e19' ) + ); + + $this->assertEquals( + 'test:percent_is_escaped:!@$%25^&*()', + $this->cache->makeKey( 'percent_is_escaped', '!@$%^&*()' ) + ); + + $this->assertEquals( + 'test:colon_is_escaped:!@$%3A^&*()', + $this->cache->makeKey( 'colon_is_escaped', '!@$:^&*()' ) + ); + + $this->assertEquals( + 'test:long_key_part_hashed:#0244f7b1811d982dd932dd7de01465ac', + $this->cache->makeKey( 'long_key_part_hashed', str_repeat( 'y', 500 ) ) + ); + } + + /** + * @dataProvider validKeyProvider + * @covers MemcachedBagOStuff::validateKeyEncoding + */ + public function testValidateKeyEncoding( $key ) { + $this->assertSame( $key, $this->cache->validateKeyEncoding( $key ) ); + } + + public function validKeyProvider() { + return [ + 'empty' => [ '' ], + 'digits' => [ '09' ], + 'letters' => [ 'AZaz' ], + 'ASCII special characters' => [ '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' ], + ]; + } + + /** + * @dataProvider invalidKeyProvider + * @covers MemcachedBagOStuff::validateKeyEncoding + */ + public function testValidateKeyEncodingThrowsException( $key ) { + $this->setExpectedException( Exception::class ); + $this->cache->validateKeyEncoding( $key ); + } + + public function invalidKeyProvider() { + return [ + [ "\x00" ], + [ ' ' ], + [ "\x1F" ], + [ "\x7F" ], + [ "\x80" ], + [ "\xFF" ], + ]; + } +} diff --git a/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php new file mode 100644 index 0000000000..dfbca706d6 --- /dev/null +++ b/tests/phpunit/includes/objectcache/RESTBagOStuffTest.php @@ -0,0 +1,96 @@ +client = + $this->getMockBuilder( MultiHttpClient::class ) + ->setConstructorArgs( [ [] ] ) + ->setMethods( [ 'run' ] ) + ->getMock(); + $this->bag = new RESTBagOStuff( [ 'client' => $this->client, 'url' => 'http://test/rest/' ] ); + } + + public function testGet() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], '"somedata"', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertEquals( 'somedata', $result ); + } + + public function testGetNotExist() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 404, 'Not found', [], 'Nothing to see here', 0 ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + } + + public function testGetBadClient() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 0, '', [], '', 'cURL has failed you today' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNREACHABLE, $this->bag->getLastError() ); + } + + public function testGetBadServer() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'GET', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 500, 'Too busy', [], 'Server is too busy', '' ] ); + $result = $this->bag->get( '42xyz42' ); + $this->assertFalse( $result ); + $this->assertEquals( BagOStuff::ERR_UNEXPECTED, $this->bag->getLastError() ); + } + + public function testPut() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'PUT', + 'url' => 'http://test/rest/42xyz42', + 'body' => '"postdata"', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->set( '42xyz42', 'postdata' ); + $this->assertTrue( $result ); + } + + public function testDelete() { + $this->client->expects( $this->once() )->method( 'run' )->with( [ + 'method' => 'DELETE', + 'url' => 'http://test/rest/42xyz42', + 'headers' => [] + // list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) + ] )->willReturn( [ 200, 'OK', [], 'Done', 0 ] ); + $result = $this->bag->delete( '42xyz42' ); + $this->assertTrue( $result ); + } +} diff --git a/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php new file mode 100644 index 0000000000..df5614d8c1 --- /dev/null +++ b/tests/phpunit/includes/objectcache/RedisBagOStuffTest.php @@ -0,0 +1,110 @@ +getMockBuilder( RedisBagOStuff::class ) + ->disableOriginalConstructor() + ->getMock(); + $this->cache = TestingAccessWrapper::newFromObject( $cache ); + } + + /** + * @covers RedisBagOStuff::unserialize + * @dataProvider unserializeProvider + */ + public function testUnserialize( $expected, $input, $message ) { + $actual = $this->cache->unserialize( $input ); + $this->assertSame( $expected, $actual, $message ); + } + + public function unserializeProvider() { + return [ + [ + -1, + '-1', + 'String representation of \'-1\'', + ], + [ + 0, + '0', + 'String representation of \'0\'', + ], + [ + 1, + '1', + 'String representation of \'1\'', + ], + [ + -1.0, + 'd:-1;', + 'Serialized negative double', + ], + [ + 'foo', + 's:3:"foo";', + 'Serialized string', + ] + ]; + } + + /** + * @covers RedisBagOStuff::serialize + * @dataProvider serializeProvider + */ + public function testSerialize( $expected, $input, $message ) { + $actual = $this->cache->serialize( $input ); + $this->assertSame( $expected, $actual, $message ); + } + + public function serializeProvider() { + return [ + [ + -1, + -1, + '-1 as integer', + ], + [ + 0, + 0, + '0 as integer', + ], + [ + 1, + 1, + '1 as integer', + ], + [ + 'd:-1;', + -1.0, + 'Negative double', + ], + [ + 's:3:"2.1";', + '2.1', + 'Decimal string', + ], + [ + 's:1:"1";', + '1', + 'String representation of 1', + ], + [ + 's:3:"foo";', + 'foo', + 'String', + ], + ]; + } +} diff --git a/tests/phpunit/includes/page/ArticleTest.php b/tests/phpunit/includes/page/ArticleTest.php new file mode 100644 index 0000000000..df4a281701 --- /dev/null +++ b/tests/phpunit/includes/page/ArticleTest.php @@ -0,0 +1,57 @@ +title = Title::makeTitle( NS_MAIN, 'SomePage' ); + $this->article = new Article( $this->title ); + } + + /** cleanup title object and its article object */ + protected function tearDown() { + parent::tearDown(); + $this->title = null; + $this->article = null; + } + + /** + * @covers Article::__get + */ + public function testImplementsGetMagic() { + $this->assertEquals( false, $this->article->mLatest, "Article __get magic" ); + } + + /** + * @depends testImplementsGetMagic + * @covers Article::__set + */ + public function testImplementsSetMagic() { + $this->article->mLatest = 2; + $this->assertEquals( 2, $this->article->mLatest, "Article __set magic" ); + } + + /** + * @covers Article::__get + * @covers Article::__set + */ + public function testGetOrSetOnNewProperty() { + $this->article->ext_someNewProperty = 12; + $this->assertEquals( 12, $this->article->ext_someNewProperty, + "Article get/set magic on new field" ); + + $this->article->ext_someNewProperty = -8; + $this->assertEquals( -8, $this->article->ext_someNewProperty, + "Article get/set magic on update to new field" ); + } +} diff --git a/tests/phpunit/includes/parser/ParserPreloadTest.php b/tests/phpunit/includes/parser/ParserPreloadTest.php new file mode 100644 index 0000000000..560b921a8d --- /dev/null +++ b/tests/phpunit/includes/parser/ParserPreloadTest.php @@ -0,0 +1,97 @@ +testParserOptions = ParserOptions::newFromUserAndLang( new User, + MediaWikiServices::getInstance()->getContentLanguage() ); + + $this->testParser = new Parser(); + $this->testParser->Options( $this->testParserOptions ); + $this->testParser->clearState(); + + $this->title = Title::newFromText( 'Preload Test' ); + } + + protected function tearDown() { + parent::tearDown(); + + unset( $this->testParser ); + unset( $this->title ); + } + + public function testPreloadSimpleText() { + $this->assertPreloaded( 'simple', 'simple' ); + } + + public function testPreloadedPreIsUnstripped() { + $this->assertPreloaded( + '
monospaced
', + '
monospaced
', + '
 in preloaded text must be unstripped (T29467)'
+		);
+	}
+
+	public function testPreloadedNowikiIsUnstripped() {
+		$this->assertPreloaded(
+			'[[Dummy title]]',
+			'[[Dummy title]]',
+			' in preloaded text must be unstripped (T29467)'
+		);
+	}
+
+	protected function assertPreloaded( $expected, $text, $msg = '' ) {
+		$this->assertEquals(
+			$expected,
+			$this->testParser->getPreloadText(
+				$text,
+				$this->title,
+				$this->testParserOptions
+			),
+			$msg
+		);
+	}
+}
diff --git a/tests/phpunit/includes/parser/PreprocessorTest.php b/tests/phpunit/includes/parser/PreprocessorTest.php
new file mode 100644
index 0000000000..6b3e05da51
--- /dev/null
+++ b/tests/phpunit/includes/parser/PreprocessorTest.php
@@ -0,0 +1,296 @@
+mOptions = ParserOptions::newFromUserAndLang( new User,
+			MediaWikiServices::getInstance()->getContentLanguage() );
+
+		$this->mPreprocessors = [];
+		foreach ( self::$classNames as $className ) {
+			$this->mPreprocessors[$className] = new $className( $this );
+		}
+	}
+
+	function getStripList() {
+		return [ 'gallery', 'display map' /* Used by Maps, see r80025 CR */, '/foo' ];
+	}
+
+	protected static function addClassArg( $testCases ) {
+		$newTestCases = [];
+		foreach ( self::$classNames as $className ) {
+			foreach ( $testCases as $testCase ) {
+				array_unshift( $testCase, $className );
+				$newTestCases[] = $testCase;
+			}
+		}
+		return $newTestCases;
+	}
+
+	public static function provideCases() {
+		// phpcs:disable Generic.Files.LineLength
+		return self::addClassArg( [
+			[ "Foo", "Foo" ],
+			[ "", "<!-- Foo -->" ],
+			[ "", "<!-- Foo --><!-- Bar -->" ],
+			[ "  ", "<!-- Foo -->  <!-- Bar -->" ],
+			[ " \n ", "<!-- Foo --> \n <!-- Bar -->" ],
+			[ " \n \n", "<!-- Foo --> \n <!-- Bar -->\n" ],
+			[ "  \n", "<!-- Foo -->  <!-- Bar -->\n" ],
+			[ "Bar", "<!-->Bar" ],
+			[ "\n== Baz ==\n", "== Foo ==\n  <!-- Bar -->\n== Baz ==\n" ],
+			[ "", "gallery" ],
+			[ "Foo  Bar", "Foo gallery Bar" ],
+			[ "", "gallery</gallery>" ],
+			[ " ", "<foo> gallery</gallery>" ],
+			[ " ", "<foo> gallery<gallery></gallery>" ],
+			[ " Foo bar ", "<noinclude> Foo bar </noinclude>" ],
+			[ "\n{{Foo}}\n", "<noinclude>\n\n</noinclude>" ],
+			[ "\n{{Foo}}\n\n", "<noinclude>\n\n</noinclude>\n" ],
+			[ "foo bar", "<gallery>foo bar" ],
+			[ "<{{foo}}>", "<>" ],
+			[ "<{{{foo}}}>", "<foo>" ],
+			[ "", "gallery</gallery</gallery>" ],
+			[ "=== Foo === ", "=== Foo === " ],
+			[ "=== Foo === ", "==<!-- -->= Foo === " ],
+			[ "=== Foo === ", "=== Foo ==<!-- -->= " ],
+			[ "=== Foo ===\n", "=== Foo ===<!-- -->\n" ],
+			[ "=== Foo === \n", "=== Foo ===<!-- --> <!-- -->\n" ],
+			[ "== Foo ==\n== Bar == \n", "== Foo ==\n== Bar == \n" ],
+			[ "===========", "===========" ],
+			[ "Foo\n=\n==\n=\n", "Foo\n=\n==\n=\n" ],
+			[ "{{Foo}}", "" ],
+			[ "\n{{Foo}}", "\n" ],
+			[ "{{Foo|bar}}", "" ],
+			[ "{{Foo|bar}}a", "a" ],
+			[ "{{Foo|bar|baz}}", "" ],
+			[ "{{Foo|1=bar}}", "" ],
+			[ "{{Foo|=bar}}", "" ],
+			[ "{{Foo|bar=baz}}", "" ],
+			[ "{{Foo|{{bar}}=baz}}", "" ],
+			[ "{{Foo|1=bar|baz}}", "" ],
+			[ "{{Foo|1=bar|2=baz}}", "" ],
+			[ "{{Foo|bar|foo=baz}}", "" ],
+			[ "{{{1}}}", "1" ],
+			[ "{{{1|}}}", "1" ],
+			[ "{{{Foo}}}", "Foo" ],
+			[ "{{{Foo|}}}", "Foo" ],
+			[ "{{{Foo|bar|baz}}}", "Foobarbaz" ],
+			[ "{{Foo}}", "{<!-- -->{Foo}}" ],
+			[ "{{{{Foobar}}}}", "{Foobar}" ],
+			[ "{{{ {{Foo}} }}}", " <template><title>Foo " ],
+			[ "{{ {{{Foo}}} }}", "" ],
+			[ "{{{{{Foo}}}}}", "" ],
+			[ "{{{{{Foo}} }}}", "<template><title>Foo " ],
+			[ "{{{{{{Foo}}}}}}", "<tplarg><title>Foo" ],
+			[ "{{{{{{Foo}}}}}", "{" ],
+			[ "[[[Foo]]", "[[[Foo]]" ],
+			[ "{{Foo|[[[[bar]]|baz]]}}", "" ], // This test is important, since it means the difference between having the [[ rule stacked or not
+			[ "{{Foo|[[[[bar]|baz]]}}", "{{Foo|[[[[bar]|baz]]}}" ],
+			[ "{{Foo|Foo [[[[bar]|baz]]}}", "{{Foo|Foo [[[[bar]|baz]]}}" ],
+			[ "Foo BarBaz", "Foo display mapBar</display map             >Baz" ],
+			[ "Foo BarBaz", "Foo display map fooBar</display map             >Baz" ],
+			[ "Foo ", "Foo gallery bar="baz" " ],
+			[ "Foo ", "Foo gallery bar="1" baz=2 " ],
+			[ "Foo", "/fooFoo<//foo>" ], # Worth blacklisting IMHO
+			[ "{{#ifexpr: ({{{1|1}}} = 2) | Foo | Bar }}", "" ],
+			[ "{{#if: {{{1|}}} | Foo | {{Bar}} }}", "" ],
+			[ "{{#if: {{{1|}}} | Foo | [[Bar]] }}", "" ],
+			[ "{{#if: {{{1|}}} | [[Foo]] | Bar }}", "" ],
+			[ "{{#if: {{{1|}}} | 1 | {{#if: {{{1|}}} | 2 | 3 }} }}", "" ],
+			[ "{{ {{Foo}}", "{{ " ],
+			[ "{{Foobar {{Foo}} {{Bar}} {{Baz}} ", "{{Foobar    " ],
+			[ "[[Foo]] |", "[[Foo]] |" ],
+			[ "{{Foo|Bar|", "{{Foo|Bar|" ],
+			[ "[[Foo]", "[[Foo]" ],
+			[ "[[Foo|Bar]", "[[Foo|Bar]" ],
+			[ "{{Foo| [[Bar] }}", "{{Foo| [[Bar] }}" ],
+			[ "{{Foo| [[Bar|Baz] }}", "{{Foo| [[Bar|Baz] }}" ],
+			[ "{{Foo|bar=[[baz]}}", "{{Foo|bar=[[baz]}}" ],
+			[ "{{foo|", "{{foo|" ],
+			[ "{{foo|}", "{{foo|}" ],
+			[ "{{foo|} }}", "" ],
+			[ "{{foo|bar=|}", "{{foo|bar=|}" ],
+			[ "{{Foo|} Bar=", "{{Foo|} Bar=" ],
+			[ "{{Foo|} Bar=}}", "" ],
+			/* [ file_get_contents( __DIR__ . '/QuoteQuran.txt' ], file_get_contents( __DIR__ . '/QuoteQuranExpanded.txt' ) ], */
+		] );
+		// phpcs:enable
+	}
+
+	/**
+	 * Get XML preprocessor tree from the preprocessor (which may not be the
+	 * native XML-based one).
+	 *
+	 * @param string $className
+	 * @param string $wikiText
+	 * @return string
+	 */
+	protected function preprocessToXml( $className, $wikiText ) {
+		$preprocessor = $this->mPreprocessors[$className];
+		if ( method_exists( $preprocessor, 'preprocessToXml' ) ) {
+			return $this->normalizeXml( $preprocessor->preprocessToXml( $wikiText ) );
+		}
+
+		$dom = $preprocessor->preprocessToObj( $wikiText );
+		if ( is_callable( [ $dom, 'saveXML' ] ) ) {
+			return $dom->saveXML();
+		} else {
+			return $this->normalizeXml( $dom->__toString() );
+		}
+	}
+
+	/**
+	 * Normalize XML string to the form that a DOMDocument saves out.
+	 *
+	 * @param string $xml
+	 * @return string
+	 */
+	protected function normalizeXml( $xml ) {
+		// Normalize self-closing tags
+		$xml = preg_replace( '!<([a-z]+)/>!', '<$1>', str_replace( ' />', '/>', $xml ) );
+		// Remove  tags, which only occur in Preprocessor_Hash and
+		// have no semantic value
+		$xml = preg_replace( '!!', '', $xml );
+		return $xml;
+	}
+
+	/**
+	 * @dataProvider provideCases
+	 */
+	public function testPreprocessorOutput( $className, $wikiText, $expectedXml ) {
+		$this->assertEquals( $this->normalizeXml( $expectedXml ),
+			$this->preprocessToXml( $className, $wikiText ) );
+	}
+
+	/**
+	 * These are more complex test cases taken out of wiki articles.
+	 */
+	public static function provideFiles() {
+		// phpcs:disable Generic.Files.LineLength
+		return self::addClassArg( [
+			[ "QuoteQuran" ], # https://en.wikipedia.org/w/index.php?title=Template:QuoteQuran/sandbox&oldid=237348988 GFDL + CC BY-SA by Striver
+			[ "Factorial" ], # https://en.wikipedia.org/w/index.php?title=Template:Factorial&oldid=98548758 GFDL + CC BY-SA by Polonium
+			[ "All_system_messages" ], # https://tl.wiktionary.org/w/index.php?title=Suleras:All_system_messages&oldid=2765 GPL text generated by MediaWiki
+			[ "Fundraising" ], # https://tl.wiktionary.org/w/index.php?title=MediaWiki:Sitenotice&oldid=5716 GFDL + CC BY-SA, copied there by Sky Harbor.
+			[ "NestedTemplates" ], # T29936
+		] );
+		// phpcs:enable
+	}
+
+	/**
+	 * @dataProvider provideFiles
+	 */
+	public function testPreprocessorOutputFiles( $className, $filename ) {
+		$folder = __DIR__ . "/../../../parser/preprocess";
+		$wikiText = file_get_contents( "$folder/$filename.txt" );
+		$output = $this->preprocessToXml( $className, $wikiText );
+
+		$expectedFilename = "$folder/$filename.expected";
+		if ( file_exists( $expectedFilename ) ) {
+			$expectedXml = $this->normalizeXml( file_get_contents( $expectedFilename ) );
+			$this->assertEquals( $expectedXml, $output );
+		} else {
+			$tempFilename = tempnam( $folder, "$filename." );
+			file_put_contents( $tempFilename, $output );
+			$this->markTestIncomplete( "File $expectedFilename missing. Output stored as $tempFilename" );
+		}
+	}
+
+	/**
+	 * Tests from T30642 · https://phabricator.wikimedia.org/T30642
+	 */
+	public static function provideHeadings() {
+		// phpcs:disable Generic.Files.LineLength
+		return self::addClassArg( [
+			/* These should become headings: */
+			[ "== h ==", "== h ==<!--c1-->" ],
+			[ "== h == 	", "== h == 	<!--c1-->" ],
+			[ "== h == 	", "== h ==<!--c1--> 	" ],
+			[ "== h == 	 	", "== h == 	<!--c1--> 	" ],
+			[ "== h ==", "== h ==<!--c1--><!--c2-->" ],
+			[ "== h == 	", "== h == 	<!--c1--><!--c2-->" ],
+			[ "== h == 	", "== h ==<!--c1--><!--c2--> 	" ],
+			[ "== h == 	 	", "== h == 	<!--c1--><!--c2--> 	" ],
+			[ "== h == 	  ", "== h == 	<!--c1-->  <!--c2-->" ],
+			[ "== h ==   	", "== h ==<!--c1-->  <!--c2--> 	" ],
+			[ "== h == 	   	", "== h == 	<!--c1-->  <!--c2--> 	" ],
+			[ "== h ==", "== h ==<!--c1--><!--c2--><!--c3-->" ],
+			[ "== h ==  ", "== h ==<!--c1-->  <!--c2--><!--c3-->" ],
+			[ "== h ==  ", "== h ==<!--c1--><!--c2-->  <!--c3-->" ],
+			[ "== h ==    ", "== h ==<!--c1-->  <!--c2-->  <!--c3-->" ],
+			[ "== h ==  ", "== h ==  <!--c1--><!--c2--><!--c3-->" ],
+			[ "== h ==    ", "== h ==  <!--c1-->  <!--c2--><!--c3-->" ],
+			[ "== h ==    ", "== h ==  <!--c1--><!--c2-->  <!--c3-->" ],
+			[ "== h ==      ", "== h ==  <!--c1-->  <!--c2-->  <!--c3-->" ],
+			[ "== h ==  ", "== h ==<!--c1--><!--c2--><!--c3-->  " ],
+			[ "== h ==    ", "== h ==<!--c1-->  <!--c2--><!--c3-->  " ],
+			[ "== h ==    ", "== h ==<!--c1--><!--c2-->  <!--c3-->  " ],
+			[ "== h ==      ", "== h ==<!--c1-->  <!--c2-->  <!--c3-->  " ],
+			[ "== h ==    ", "== h ==  <!--c1--><!--c2--><!--c3-->  " ],
+			[ "== h ==      ", "== h ==  <!--c1-->  <!--c2--><!--c3-->  " ],
+			[ "== h ==      ", "== h ==  <!--c1--><!--c2-->  <!--c3-->  " ],
+			[ "== h ==        ", "== h ==  <!--c1-->  <!--c2-->  <!--c3-->  " ],
+			[ "== h == 	", "== h ==<!--c1--> 	<!--c2-->" ],
+			[ "== h == 	 	", "== h == 	<!--c1--> 	<!--c2-->" ],
+			[ "== h == 	 	", "== h ==<!--c1--> 	<!--c2--> 	" ],
+
+			/* These are not working: */
+			[ "== h == x   ", "== h == x <!--c1--><!--c2--><!--c3-->  " ],
+			[ "== h == x   ", "== h ==<!--c1--> x <!--c2--><!--c3-->  " ],
+			[ "== h == x ", "== h ==<!--c1--><!--c2--><!--c3--> x " ],
+		] );
+		// phpcs:enable
+	}
+
+	/**
+	 * @dataProvider provideHeadings
+	 */
+	public function testHeadings( $className, $wikiText, $expectedXml ) {
+		$this->assertEquals( $this->normalizeXml( $expectedXml ),
+			$this->preprocessToXml( $className, $wikiText ) );
+	}
+}
diff --git a/tests/phpunit/includes/parser/TidyTest.php b/tests/phpunit/includes/parser/TidyTest.php
new file mode 100644
index 0000000000..898ef2d163
--- /dev/null
+++ b/tests/phpunit/includes/parser/TidyTest.php
@@ -0,0 +1,64 @@
+markTestSkipped( 'Tidy not found' );
+		}
+	}
+
+	/**
+	 * @dataProvider provideTestWrapping
+	 */
+	public function testTidyWrapping( $expected, $text, $msg = '' ) {
+		$text = MWTidy::tidy( $text );
+		// We don't care about where Tidy wants to stick is 

s + $text = trim( preg_replace( '##', '', $text ) ); + // Windows, we love you! + $text = str_replace( "\r", '', $text ); + $this->assertEquals( $expected, $text, $msg ); + } + + public static function provideTestWrapping() { + $testMathML = <<<'MathML' + + + a + + + x + 2 + + + + b + + x + + + c + + +MathML; + return [ + [ + 'foo', + 'foo', + ' should survive tidy' + ], + [ + 'foo', + 'foo', + ' should survive tidy' + ], + [ 'foo', 'foo', ' should survive tidy' ], + [ "foo", 'foo', ' should survive tidy' ], + [ "foo", 'foo', ' should survive tidy' ], + [ $testMathML, $testMathML, ' should survive tidy' ], + ]; + } +} diff --git a/tests/phpunit/includes/password/PasswordFactoryTest.php b/tests/phpunit/includes/password/PasswordFactoryTest.php new file mode 100644 index 0000000000..a7b3557516 --- /dev/null +++ b/tests/phpunit/includes/password/PasswordFactoryTest.php @@ -0,0 +1,124 @@ +assertEquals( [ '' ], array_keys( $pf->getTypes() ) ); + $this->assertEquals( '', $pf->getDefaultType() ); + + $pf = new PasswordFactory( [ + 'foo' => [ 'class' => 'FooPassword' ], + 'bar' => [ 'class' => 'BarPassword', 'baz' => 'boom' ], + ], 'foo' ); + $this->assertEquals( [ '', 'foo', 'bar' ], array_keys( $pf->getTypes() ) ); + $this->assertArraySubset( [ 'class' => 'BarPassword', 'baz' => 'boom' ], $pf->getTypes()['bar'] ); + $this->assertEquals( 'foo', $pf->getDefaultType() ); + } + + public function testRegister() { + $pf = new PasswordFactory; + $pf->register( 'foo', [ 'class' => InvalidPassword::class ] ); + $this->assertArrayHasKey( 'foo', $pf->getTypes() ); + } + + public function testSetDefaultType() { + $pf = new PasswordFactory; + $pf->register( '1', [ 'class' => InvalidPassword::class ] ); + $pf->register( '2', [ 'class' => InvalidPassword::class ] ); + $pf->setDefaultType( '1' ); + $this->assertSame( '1', $pf->getDefaultType() ); + $pf->setDefaultType( '2' ); + $this->assertSame( '2', $pf->getDefaultType() ); + } + + /** + * @expectedException Exception + */ + public function testSetDefaultTypeError() { + $pf = new PasswordFactory; + $pf->setDefaultType( 'bogus' ); + } + + public function testInit() { + $config = new HashConfig( [ + 'PasswordConfig' => [ + 'foo' => [ 'class' => InvalidPassword::class ], + ], + 'PasswordDefault' => 'foo' + ] ); + $pf = new PasswordFactory; + $pf->init( $config ); + $this->assertSame( 'foo', $pf->getDefaultType() ); + $this->assertArrayHasKey( 'foo', $pf->getTypes() ); + } + + public function testNewFromCiphertext() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pw = $pf->newFromCiphertext( ':B:salt:d529e941509eb9e9b9cfaeae1fe7ca23' ); + $this->assertInstanceOf( MWSaltedPassword::class, $pw ); + } + + public function provideNewFromCiphertextErrors() { + return [ [ 'blah' ], [ ':blah:' ] ]; + } + + /** + * @dataProvider provideNewFromCiphertextErrors + * @expectedException PasswordError + */ + public function testNewFromCiphertextErrors( $hash ) { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->newFromCiphertext( $hash ); + } + + public function testNewFromType() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pw = $pf->newFromType( 'B' ); + $this->assertInstanceOf( MWSaltedPassword::class, $pw ); + } + + /** + * @expectedException PasswordError + */ + public function testNewFromTypeError() { + $pf = new PasswordFactory; + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->newFromType( 'bogus' ); + } + + public function testNewFromPlaintext() { + $pf = new PasswordFactory; + $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->setDefaultType( 'A' ); + + $this->assertInstanceOf( InvalidPassword::class, $pf->newFromPlaintext( null ) ); + $this->assertInstanceOf( MWOldPassword::class, $pf->newFromPlaintext( 'password' ) ); + $this->assertInstanceOf( MWSaltedPassword::class, + $pf->newFromPlaintext( 'password', $pf->newFromType( 'B' ) ) ); + } + + public function testNeedsUpdate() { + $pf = new PasswordFactory; + $pf->register( 'A', [ 'class' => MWOldPassword::class ] ); + $pf->register( 'B', [ 'class' => MWSaltedPassword::class ] ); + $pf->setDefaultType( 'A' ); + + $this->assertFalse( $pf->needsUpdate( $pf->newFromType( 'A' ) ) ); + $this->assertTrue( $pf->needsUpdate( $pf->newFromType( 'B' ) ) ); + } + + public function testGenerateRandomPasswordString() { + $this->assertSame( 13, strlen( PasswordFactory::generateRandomPasswordString( 13 ) ) ); + } + + public function testNewInvalidPassword() { + $this->assertInstanceOf( InvalidPassword::class, PasswordFactory::newInvalidPassword() ); + } +} diff --git a/tests/phpunit/includes/password/PasswordTest.php b/tests/phpunit/includes/password/PasswordTest.php new file mode 100644 index 0000000000..61a5147277 --- /dev/null +++ b/tests/phpunit/includes/password/PasswordTest.php @@ -0,0 +1,33 @@ +newFromPlaintext( null ); + + $this->assertInstanceOf( InvalidPassword::class, $invalid ); + } +} diff --git a/tests/phpunit/includes/preferences/FiltersTest.php b/tests/phpunit/includes/preferences/FiltersTest.php new file mode 100644 index 0000000000..60b01b880c --- /dev/null +++ b/tests/phpunit/includes/preferences/FiltersTest.php @@ -0,0 +1,141 @@ +filterFromForm( '0' ) ); + self::assertSame( 3, $filter->filterFromForm( '3' ) ); + self::assertSame( '123', $filter->filterForForm( '123' ) ); + } + + /** + * @covers MediaWiki\Preferences\TimezoneFilter::filterFromForm() + * @dataProvider provideTimezoneFilter + * + * @param string $input + * @param string $expected + */ + public function testTimezoneFilter( $input, $expected ) { + $filter = new TimezoneFilter(); + $result = $filter->filterFromForm( $input ); + self::assertEquals( $expected, $result ); + } + + public function provideTimezoneFilter() { + return [ + [ 'ZoneInfo', 'Offset|0' ], + [ 'ZoneInfo|bogus', 'Offset|0' ], + [ 'System', 'System' ], + [ '2:30', 'Offset|150' ], + ]; + } + + /** + * @covers MediaWiki\Preferences\MultiUsernameFilter::filterFromForm() + * @dataProvider provideMultiUsernameFilterFrom + * + * @param string $input + * @param string|null $expected + */ + public function testMultiUsernameFilterFrom( $input, $expected ) { + $filter = $this->makeMultiUsernameFilter(); + $result = $filter->filterFromForm( $input ); + self::assertSame( $expected, $result ); + } + + public function provideMultiUsernameFilterFrom() { + return [ + [ '', null ], + [ "\n\n\n", null ], + [ 'Foo', '1' ], + [ "\n\n\nFoo\nBar\n", "1\n2" ], + [ "Baz\nInvalid\nFoo", "3\n1" ], + [ "Invalid", null ], + [ "Invalid\n\n\nInvalid\n", null ], + ]; + } + + /** + * @covers MediaWiki\Preferences\MultiUsernameFilter::filterForForm() + * @dataProvider provideMultiUsernameFilterFor + * + * @param string $input + * @param string $expected + */ + public function testMultiUsernameFilterFor( $input, $expected ) { + $filter = $this->makeMultiUsernameFilter(); + $result = $filter->filterForForm( $input ); + self::assertSame( $expected, $result ); + } + + public function provideMultiUsernameFilterFor() { + return [ + [ '', '' ], + [ "\n", '' ], + [ '1', 'Foo' ], + [ "\n1\n\n2\377\n", "Foo\nBar" ], + [ "666\n667", '' ], + ]; + } + + private function makeMultiUsernameFilter() { + $userMapping = [ + 'Foo' => 1, + 'Bar' => 2, + 'Baz' => 3, + ]; + $flipped = array_flip( $userMapping ); + $idLookup = self::getMockBuilder( CentralIdLookup::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'centralIdsFromNames', 'namesFromCentralIds' ] ) + ->getMockForAbstractClass(); + + $idLookup->method( 'centralIdsFromNames' ) + ->will( self::returnCallback( function ( $names ) use ( $userMapping ) { + $ids = []; + foreach ( $names as $name ) { + $ids[] = $userMapping[$name] ?? null; + } + return array_filter( $ids, 'is_numeric' ); + } ) ); + $idLookup->method( 'namesFromCentralIds' ) + ->will( self::returnCallback( function ( $ids ) use ( $flipped ) { + $names = []; + foreach ( $ids as $id ) { + $names[] = $flipped[$id] ?? null; + } + return array_filter( $names, 'is_string' ); + } ) ); + + return new MultiUsernameFilter( $idLookup ); + } +} diff --git a/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php b/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php new file mode 100644 index 0000000000..46c697f8ba --- /dev/null +++ b/tests/phpunit/includes/registration/ExtensionJsonValidatorTest.php @@ -0,0 +1,97 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + * + */ + +/** + * @covers ExtensionJsonValidator + */ +class ExtensionJsonValidatorTest extends MediaWikiTestCase { + + /** + * @dataProvider provideValidate + */ + public function testValidate( $file, $expected ) { + // If a dependency is missing, skip this test. + $validator = new ExtensionJsonValidator( function ( $msg ) { + $this->markTestSkipped( $msg ); + } ); + + if ( is_string( $expected ) ) { + $this->setExpectedException( + ExtensionJsonValidationError::class, + $expected + ); + } + + $dir = __DIR__ . '/../../data/registration/'; + $this->assertSame( + $expected, + $validator->validate( $dir . $file ) + ); + } + + public function provideValidate() { + return [ + [ + 'notjson.txt', + 'notjson.txt is not valid JSON' + ], + [ + 'duplicate_keys.json', + 'Duplicate key: name' + ], + [ + 'no_manifest_version.json', + 'no_manifest_version.json does not have manifest_version set.' + ], + [ + 'old_manifest_version.json', + 'old_manifest_version.json is using a non-supported schema version' + ], + [ + 'newer_manifest_version.json', + 'newer_manifest_version.json is using a non-supported schema version' + ], + [ + 'bad_spdx.json', + "bad_spdx.json did not pass validation. +[license-name] Invalid SPDX license identifier, see " + ], + [ + 'invalid.json', + "invalid.json did not pass validation. +[license-name] Array value found, but a string is required" + ], + [ + 'good.json', + true + ], + [ + 'bad_url.json', 'bad_url.json did not pass validation. +[url] Should use HTTPS for www.mediawiki.org URLs' + ], + [ + 'bad_url2.json', 'bad_url2.json did not pass validation. +[url] Should use www.mediawiki.org domain +[url] Should use HTTPS for www.mediawiki.org URLs' + ] + ]; + } + +} diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php new file mode 100644 index 0000000000..cdd5c63eff --- /dev/null +++ b/tests/phpunit/includes/registration/ExtensionProcessorTest.php @@ -0,0 +1,829 @@ +dir = __DIR__ . '/FooBar/extension.json'; + $this->dirname = dirname( $this->dir ); + } + + /** + * 'name' is absolutely required + * + * @var array + */ + public static $default = [ + 'name' => 'FooBar', + ]; + + public function testExtractInfo() { + // Test that attributes that begin with @ are ignored + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default + [ + '@metadata' => [ 'foobarbaz' ], + 'AnAttribute' => [ 'omg' ], + 'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ], + 'SpecialPages' => [ 'Foo' => 'SpecialFoo' ], + 'callback' => 'FooBar::onRegistration', + ], 1 ); + + $extracted = $processor->getExtractedInfo(); + $attributes = $extracted['attributes']; + $this->assertArrayHasKey( 'AnAttribute', $attributes ); + $this->assertArrayNotHasKey( '@metadata', $attributes ); + $this->assertArrayNotHasKey( 'AutoloadClasses', $attributes ); + $this->assertSame( + [ 'FooBar' => 'FooBar::onRegistration' ], + $extracted['callbacks'] + ); + $this->assertSame( + [ 'Foo' => 'SpecialFoo' ], + $extracted['globals']['wgSpecialPages'] + ); + } + + public function testExtractNamespaces() { + // Test that namespace IDs can be overwritten + if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) { + define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 ); + } + + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default + [ + 'namespaces' => [ + [ + 'id' => 332200, + 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', + 'name' => 'Test_A', + 'defaultcontentmodel' => 'TestModel', + 'gender' => [ + 'male' => 'Male test', + 'female' => 'Female test', + ], + 'subpages' => true, + 'content' => true, + 'protection' => 'userright', + ], + [ // Test_X will use ID 123456 not 334400 + 'id' => 334400, + 'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', + 'name' => 'Test_X', + 'defaultcontentmodel' => 'TestModel' + ], + ] + ], 1 ); + + $extracted = $processor->getExtractedInfo(); + + $this->assertArrayHasKey( + 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A', + $extracted['defines'] + ); + $this->assertArrayNotHasKey( + 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', + $extracted['defines'] + ); + + $this->assertSame( + $extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A'], + 332200 + ); + + $this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] ); + $this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] ); + $this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] ); + $this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] ); + + $this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] ); + $this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] ); + $this->assertSame( + [ 'male' => 'Male test', 'female' => 'Female test' ], + $extracted['globals']['wgExtraGenderNamespaces'][332200] + ); + // A has subpages, X does not + $this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] ); + $this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] ); + } + + public static function provideRegisterHooks() { + $merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ]; + // Format: + // Current $wgHooks + // Content in extension.json + // Expected value of $wgHooks + return [ + // No hooks + [ + [], + self::$default, + $merge, + ], + // No current hooks, adding one for "FooBaz" in string format + [ + [], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, + ], + // Hook for "FooBaz", adding another one + [ + [ 'FooBaz' => [ 'PriorCallback' ] ], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge, + ], + // No current hooks, adding one for "FooBaz" in verbose array format + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default, + [ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge, + ], + // Hook for "BarBaz", adding one for "FooBaz" + [ + [ 'BarBaz' => [ 'BarBazCallback' ] ], + [ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default, + [ + 'BarBaz' => [ 'BarBazCallback' ], + 'FooBaz' => [ 'FooBazCallback' ], + ] + $merge, + ], + // Callbacks for FooBaz wrapped in an array + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default, + [ + 'FooBaz' => [ 'Callback1' ], + ] + $merge, + ], + // Multiple callbacks for FooBaz hook + [ + [], + [ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default, + [ + 'FooBaz' => [ 'Callback1', 'Callback2' ], + ] + $merge, + ], + ]; + } + + /** + * @dataProvider provideRegisterHooks + */ + public function testRegisterHooks( $pre, $info, $expected ) { + $processor = new MockExtensionProcessor( [ 'wgHooks' => $pre ] ); + $processor->extractInfo( $this->dir, $info, 1 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( $expected, $extracted['globals']['wgHooks'] ); + } + + public function testExtractConfig1() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => 'somevalue', + 'Foo' => 10, + '@IGNORED' => 'yes', + ], + ] + self::$default; + $info2 = [ + 'config' => [ + '_prefix' => 'eg', + 'Bar' => 'somevalue' + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 1 ); + $processor->extractInfo( $this->dir, $info2, 1 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + $this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] ); + // Custom prefix: + $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); + } + + public function testExtractConfig2() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + 'Foo' => [ 'value' => 10 ], + 'Path' => [ 'value' => 'foo.txt', 'path' => true ], + 'Namespaces' => [ + 'value' => [ + '10' => true, + '12' => false, + ], + 'merge_strategy' => 'array_plus', + ], + ], + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ], + 'config_prefix' => 'eg', + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 2 ); + $processor->extractInfo( $this->dir, $info2, 2 ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + $this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] ); + // Custom prefix: + $this->assertEquals( 'somevalue', $extracted['globals']['egBar'] ); + $this->assertSame( + [ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ], + $extracted['globals']['wgNamespaces'] + ); + } + + /** + * @expectedException RuntimeException + */ + public function testDuplicateConfigKey1() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => '', + ] + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => 'g', + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 1 ); + $processor->extractInfo( $this->dir, $info2, 1 ); + } + + /** + * @expectedException RuntimeException + */ + public function testDuplicateConfigKey2() { + $processor = new ExtensionProcessor; + $info = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ] + ] + self::$default; + $info2 = [ + 'config' => [ + 'Bar' => [ 'value' => 'somevalue' ], + ], + 'name' => 'FooBar2', + ]; + $processor->extractInfo( $this->dir, $info, 2 ); + $processor->extractInfo( $this->dir, $info2, 2 ); + } + + public static function provideExtractExtensionMessagesFiles() { + $dir = __DIR__ . '/FooBar/'; + return [ + [ + [ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ], + [ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ] + ], + [ + [ + 'ExtensionMessagesFiles' => [ + 'FooBarAlias' => 'FooBar.alias.php', + 'FooBarMagic' => 'FooBar.magic.i18n.php', + ], + ], + [ + 'wgExtensionMessagesFiles' => [ + 'FooBarAlias' => $dir . 'FooBar.alias.php', + 'FooBarMagic' => $dir . 'FooBar.magic.i18n.php', + ], + ], + ], + ]; + } + + /** + * @dataProvider provideExtractExtensionMessagesFiles + */ + public function testExtractExtensionMessagesFiles( $input, $expected ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expected as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + } + + public static function provideExtractMessagesDirs() { + $dir = __DIR__ . '/FooBar/'; + return [ + [ + [ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ], + [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ] + ], + [ + [ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ], + [ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ] + ], + ]; + } + + /** + * @dataProvider provideExtractMessagesDirs + */ + public function testExtractMessagesDirs( $input, $expected ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expected as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + } + + public function testExtractCredits() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, self::$default, 1 ); + $this->setExpectedException( Exception::class ); + $processor->extractInfo( $this->dir, self::$default, 1 ); + } + + /** + * @dataProvider provideExtractResourceLoaderModules + */ + public function testExtractResourceLoaderModules( + $input, + array $expectedGlobals, + array $expectedAttribs = [] + ) { + $processor = new ExtensionProcessor(); + $processor->extractInfo( $this->dir, $input + self::$default, 1 ); + $out = $processor->getExtractedInfo(); + foreach ( $expectedGlobals as $key => $value ) { + $this->assertEquals( $value, $out['globals'][$key] ); + } + foreach ( $expectedAttribs as $key => $value ) { + $this->assertEquals( $value, $out['attributes'][$key] ); + } + } + + public static function provideExtractResourceLoaderModules() { + $dir = __DIR__ . '/FooBar'; + return [ + // Generic module with localBasePath/remoteExtPath specified + [ + // Input + [ + 'ResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foobar.js', + 'localBasePath' => '', + 'remoteExtPath' => 'FooBar', + ], + ], + ], + // Expected + [ + 'wgResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foobar.js', + 'localBasePath' => $dir, + 'remoteExtPath' => 'FooBar', + ], + ], + ], + ], + // ResourceFileModulePaths specified: + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => 'modules', + 'remoteExtPath' => 'FooBar/modules', + ], + 'ResourceModules' => [ + // No paths + 'test.foo' => [ + 'styles' => 'foo.js', + ], + // Different paths set + 'test.bar' => [ + 'styles' => 'bar.js', + 'localBasePath' => 'subdir', + 'remoteExtPath' => 'FooBar/subdir', + ], + // Custom class with no paths set + 'test.class' => [ + 'class' => 'FooBarModule', + 'extra' => 'argument', + ], + // Custom class with a localBasePath + 'test.class.with.path' => [ + 'class' => 'FooBarPathModule', + 'extra' => 'argument', + 'localBasePath' => '', + ] + ], + ], + // Expected + [ + 'wgResourceModules' => [ + 'test.foo' => [ + 'styles' => 'foo.js', + 'localBasePath' => "$dir/modules", + 'remoteExtPath' => 'FooBar/modules', + ], + 'test.bar' => [ + 'styles' => 'bar.js', + 'localBasePath' => "$dir/subdir", + 'remoteExtPath' => 'FooBar/subdir', + ], + 'test.class' => [ + 'class' => 'FooBarModule', + 'extra' => 'argument', + 'localBasePath' => "$dir/modules", + 'remoteExtPath' => 'FooBar/modules', + ], + 'test.class.with.path' => [ + 'class' => 'FooBarPathModule', + 'extra' => 'argument', + 'localBasePath' => $dir, + 'remoteExtPath' => 'FooBar/modules', + ] + ], + ], + ], + // ResourceModuleSkinStyles with file module paths + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => '', + 'remoteSkinPath' => 'FooBar', + ], + 'ResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + ] + ], + ], + // Expected + [ + 'wgResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'localBasePath' => $dir, + 'remoteSkinPath' => 'FooBar', + ], + ], + ], + ], + // ResourceModuleSkinStyles with file module paths and an override + [ + // Input + [ + 'ResourceFileModulePaths' => [ + 'localBasePath' => '', + 'remoteSkinPath' => 'FooBar', + ], + 'ResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'remoteSkinPath' => 'BarFoo' + ], + ], + ], + // Expected + [ + 'wgResourceModuleSkinStyles' => [ + 'foobar' => [ + 'test.foo' => 'foo.css', + 'localBasePath' => $dir, + 'remoteSkinPath' => 'BarFoo', + ], + ], + ], + ], + 'QUnit test module' => [ + // Input + [ + 'QUnitTestModule' => [ + 'localBasePath' => '', + 'remoteExtPath' => 'Foo', + 'scripts' => 'bar.js', + ], + ], + // Expected + [], + [ + 'QUnitTestModules' => [ + 'test.FooBar' => [ + 'localBasePath' => $dir, + 'remoteExtPath' => 'Foo', + 'scripts' => 'bar.js', + ], + ], + ], + ], + ]; + } + + public static function provideSetToGlobal() { + return [ + [ + [ 'wgAPIModules', 'wgAvailableRights' ], + [], + [ + 'APIModules' => [ 'foobar' => 'ApiFooBar' ], + 'AvailableRights' => [ 'foobar', 'unfoobar' ], + ], + [ + 'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ], + 'wgAvailableRights' => [ 'foobar', 'unfoobar' ], + ], + ], + [ + [ 'wgAPIModules', 'wgAvailableRights' ], + [ + 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ], + 'wgAvailableRights' => [ 'barbaz' ] + ], + [ + 'APIModules' => [ 'foobar' => 'ApiFooBar' ], + 'AvailableRights' => [ 'foobar', 'unfoobar' ], + ], + [ + 'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ], + 'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ], + ], + ], + [ + [ 'wgGroupPermissions' ], + [ + 'wgGroupPermissions' => [ + 'sysop' => [ 'delete' ] + ], + ], + [ + 'GroupPermissions' => [ + 'sysop' => [ 'undelete' ], + 'user' => [ 'edit' ] + ], + ], + [ + 'wgGroupPermissions' => [ + 'sysop' => [ 'delete', 'undelete' ], + 'user' => [ 'edit' ] + ], + ] + ] + ]; + } + + /** + * Attributes under manifest_version 2 + */ + public function testExtractAttributes() { + $processor = new ExtensionProcessor(); + // Load FooBar extension + $processor->extractInfo( $this->dir, [ 'name' => 'FooBar' ], 2 ); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'Baz', + 'attributes' => [ + // Loaded + 'FooBar' => [ + 'Plugins' => [ + 'ext.baz.foobar', + ], + ], + // Not loaded + 'FizzBuzz' => [ + 'MorePlugins' => [ + 'ext.baz.fizzbuzz', + ], + ], + ], + ], + 2 + ); + + $info = $processor->getExtractedInfo(); + $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); + $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); + $this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); + } + + /** + * Attributes under manifest_version 1 + */ + public function testAttributes1() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'FooBar', + 'FooBarPlugins' => [ + 'ext.baz.foobar', + ], + 'FizzBuzzMorePlugins' => [ + 'ext.baz.fizzbuzz', + ], + ], + 1 + ); + $processor->extractInfo( + $this->dir, + [ + 'name' => 'FooBar2', + 'FizzBuzzMorePlugins' => [ + 'ext.bar.fizzbuzz', + ] + ], + 1 + ); + + $info = $processor->getExtractedInfo(); + $this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] ); + $this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] ); + $this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] ); + $this->assertSame( + [ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ], + $info['attributes']['FizzBuzzMorePlugins'] + ); + } + + public function testAttributes1_notarray() { + $processor = new ExtensionProcessor(); + $this->setExpectedException( + InvalidArgumentException::class, + "The value for 'FooBarPlugins' should be an array (from {$this->dir})" + ); + $processor->extractInfo( + $this->dir, + [ + 'FooBarPlugins' => 'ext.baz.foobar', + ] + self::$default, + 1 + ); + } + + public function testExtractPathBasedGlobal() { + $processor = new ExtensionProcessor(); + $processor->extractInfo( + $this->dir, + [ + 'ParserTestFiles' => [ + 'tests/parserTests.txt', + 'tests/extraParserTests.txt', + ], + 'ServiceWiringFiles' => [ + 'includes/ServiceWiring.php' + ], + ] + self::$default, + 1 + ); + $globals = $processor->getExtractedInfo()['globals']; + $this->assertArrayHasKey( 'wgParserTestFiles', $globals ); + $this->assertSame( [ + "{$this->dirname}/tests/parserTests.txt", + "{$this->dirname}/tests/extraParserTests.txt" + ], $globals['wgParserTestFiles'] ); + $this->assertArrayHasKey( 'wgServiceWiringFiles', $globals ); + $this->assertSame( [ + "{$this->dirname}/includes/ServiceWiring.php" + ], $globals['wgServiceWiringFiles'] ); + } + + public function testGetRequirements() { + $info = self::$default + [ + 'requires' => [ + 'MediaWiki' => '>= 1.25.0', + 'platform' => [ + 'php' => '>= 5.5.9' + ], + 'extensions' => [ + 'Bar' => '*' + ] + ] + ]; + $processor = new ExtensionProcessor(); + $this->assertSame( + $info['requires'], + $processor->getRequirements( $info, false ) + ); + $this->assertSame( + [], + $processor->getRequirements( [], false ) + ); + } + + public function testGetDevRequirements() { + $info = self::$default + [ + 'dev-requires' => [ + 'MediaWiki' => '>= 1.31.0', + 'platform' => [ + 'ext-foo' => '*', + ], + 'skins' => [ + 'Baz' => '*', + ], + 'extensions' => [ + 'Biz' => '*', + ], + ], + ]; + $processor = new ExtensionProcessor(); + $this->assertSame( + $info['dev-requires'], + $processor->getRequirements( $info, true ) + ); + // Set some standard requirements, so we can test merging + $info['requires'] = [ + 'MediaWiki' => '>= 1.25.0', + 'platform' => [ + 'php' => '>= 5.5.9' + ], + 'extensions' => [ + 'Bar' => '*' + ] + ]; + $this->assertSame( + [ + 'MediaWiki' => '>= 1.25.0 >= 1.31.0', + 'platform' => [ + 'php' => '>= 5.5.9', + 'ext-foo' => '*', + ], + 'extensions' => [ + 'Bar' => '*', + 'Biz' => '*', + ], + 'skins' => [ + 'Baz' => '*', + ], + ], + $processor->getRequirements( $info, true ) + ); + + // If there's no dev-requires, it just returns requires + unset( $info['dev-requires'] ); + $this->assertSame( + $info['requires'], + $processor->getRequirements( $info, true ) + ); + } + + public function testGetExtraAutoloaderPaths() { + $processor = new ExtensionProcessor(); + $this->assertSame( + [ "{$this->dirname}/vendor/autoload.php" ], + $processor->getExtraAutoloaderPaths( $this->dirname, [ + 'load_composer_autoloader' => true, + ] ) + ); + } + + /** + * Verify that extension.schema.json is in sync with ExtensionProcessor + * + * @coversNothing + */ + public function testGlobalSettingsDocumentedInSchema() { + global $IP; + $globalSettings = TestingAccessWrapper::newFromClass( + ExtensionProcessor::class )->globalSettings; + + $version = ExtensionRegistry::MANIFEST_VERSION; + $schema = FormatJson::decode( + file_get_contents( "$IP/docs/extension.schema.v$version.json" ), + true + ); + $missing = []; + foreach ( $globalSettings as $global ) { + if ( !isset( $schema['properties'][$global] ) ) { + $missing[] = $global; + } + } + + $this->assertEquals( [], $missing, + "The following global settings are not documented in docs/extension.schema.json" ); + } +} + +/** + * Allow overriding the default value of $this->globals + * so we can test merging + */ +class MockExtensionProcessor extends ExtensionProcessor { + public function __construct( $globals = [] ) { + $this->globals = $globals + $this->globals; + } +} diff --git a/tests/phpunit/includes/registration/VersionCheckerTest.php b/tests/phpunit/includes/registration/VersionCheckerTest.php new file mode 100644 index 0000000000..e824e3f02c --- /dev/null +++ b/tests/phpunit/includes/registration/VersionCheckerTest.php @@ -0,0 +1,479 @@ +assertEquals( $expected, !(bool)$checker->checkArray( [ + 'FakeExtension' => [ + 'MediaWiki' => $constraint, + ], + ] ) ); + } + + public static function provideMediaWikiCheck() { + return [ + // [ $wgVersion, constraint, expected ] + [ '1.25alpha', '>= 1.26', false ], + [ '1.25.0', '>= 1.26', false ], + [ '1.26alpha', '>= 1.26', true ], + [ '1.26alpha', '>= 1.26.0', true ], + [ '1.26alpha', '>= 1.26.0-stable', false ], + [ '1.26.0', '>= 1.26.0-stable', true ], + [ '1.26.1', '>= 1.26.0-stable', true ], + [ '1.27.1', '>= 1.26.0-stable', true ], + [ '1.26alpha', '>= 1.26.1', false ], + [ '1.26alpha', '>= 1.26alpha', true ], + [ '1.26alpha', '>= 1.25', true ], + [ '1.26.0-alpha.14', '>= 1.26.0-alpha.15', false ], + [ '1.26.0-alpha.14', '>= 1.26.0-alpha.10', true ], + [ '1.26.1', '>= 1.26.2, <=1.26.0', false ], + [ '1.26.1', '^1.26.2', false ], + // Accept anything for un-parsable version strings + [ '1.26mwf14', '== 1.25alpha', true ], + [ 'totallyinvalid', '== 1.0', true ], + ]; + } + + /** + * @dataProvider providePhpValidCheck + */ + public function testPhpValidCheck( $phpVersion, $constraint, $expected ) { + $checker = new VersionChecker( '1.0.0', $phpVersion, [] ); + $this->assertEquals( $expected, !(bool)$checker->checkArray( [ + 'FakeExtension' => [ + 'platform' => [ + 'php' => $constraint, + ], + ], + ] ) ); + } + + public static function providePhpValidCheck() { + return [ + // [ phpVersion, constraint, expected ] + [ '7.0.23', '>= 7.0.0', true ], + [ '7.0.23', '^7.1.0', false ], + [ '7.0.23', '7.0.23', true ], + ]; + } + + /** + * @expectedException UnexpectedValueException + */ + public function testPhpInvalidConstraint() { + $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); + $checker->checkArray( [ + 'FakeExtension' => [ + 'platform' => [ + 'php' => 'totallyinvalid', + ], + ], + ] ); + } + + /** + * @dataProvider providePhpInvalidVersion + * @expectedException UnexpectedValueException + */ + public function testPhpInvalidVersion( $phpVersion ) { + $checker = new VersionChecker( '1.0.0', $phpVersion, [] ); + } + + public static function providePhpInvalidVersion() { + return [ + // [ phpVersion ] + [ '7.abc' ], + [ '5.a.x' ], + ]; + } + + /** + * @dataProvider provideType + */ + public function testType( $given, $expected ) { + $checker = new VersionChecker( + '1.0.0', + '7.0.0', + [ 'phpLoadedExtension' ], + [ + 'presentAbility' => true, + 'presentAbilityWithMessage' => true, + 'missingAbility' => false, + 'missingAbilityWithMessage' => false, + ], + [ + 'presentAbilityWithMessage' => 'Present.', + 'missingAbilityWithMessage' => 'Missing.', + ] + ); + $checker->setLoadedExtensionsAndSkins( [ + 'FakeDependency' => [ + 'version' => '1.0.0', + ], + 'NoVersionGiven' => [], + ] ); + $this->assertEquals( $expected, $checker->checkArray( [ + 'FakeExtension' => $given, + ] ) ); + } + + public static function provideType() { + return [ + // valid type + [ + [ + 'extensions' => [ + 'FakeDependency' => '1.0.0', + ], + ], + [], + ], + [ + [ + 'MediaWiki' => '1.0.0', + ], + [], + ], + [ + [ + 'extensions' => [ + 'NoVersionGiven' => '*', + ], + ], + [], + ], + [ + [ + 'extensions' => [ + 'NoVersionGiven' => '1.0', + ], + ], + [ + [ + 'incompatible' => 'FakeExtension', + 'type' => 'incompatible-extensions', + 'msg' => 'NoVersionGiven does not expose its version, but FakeExtension requires: 1.0.', + ], + ], + ], + [ + [ + 'extensions' => [ + 'Missing' => '*', + ], + ], + [ + [ + 'missing' => 'Missing', + 'type' => 'missing-extensions', + 'msg' => 'FakeExtension requires Missing to be installed.', + ], + ], + ], + [ + [ + 'extensions' => [ + 'FakeDependency' => '2.0.0', + ], + ], + [ + [ + 'incompatible' => 'FakeExtension', + 'type' => 'incompatible-extensions', + // phpcs:ignore Generic.Files.LineLength.TooLong + 'msg' => 'FakeExtension is not compatible with the current installed version of FakeDependency (1.0.0), it requires: 2.0.0.', + ], + ], + ], + [ + [ + 'skins' => [ + 'FakeSkin' => '*', + ], + ], + [ + [ + 'missing' => 'FakeSkin', + 'type' => 'missing-skins', + 'msg' => 'FakeExtension requires FakeSkin to be installed.', + ], + ], + ], + [ + [ + 'platform' => [ + 'ext-phpLoadedExtension' => '*', + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ext-phpMissingExtension' => '*', + ], + ], + [ + [ + 'missing' => 'phpMissingExtension', + 'type' => 'missing-phpExtension', + // phpcs:ignore Generic.Files.LineLength.TooLong + 'msg' => 'FakeExtension requires phpMissingExtension PHP extension to be installed.', + ], + ], + ], + [ + [ + 'platform' => [ + 'ability-presentAbility' => true, + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ability-presentAbilityWithMessage' => true, + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ability-presentAbility' => false, + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ability-presentAbilityWithMessage' => false, + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ability-missingAbility' => true, + ], + ], + [ + [ + 'missing' => 'missingAbility', + 'type' => 'missing-ability', + 'msg' => 'FakeExtension requires "missingAbility" ability', + ], + ], + ], + [ + [ + 'platform' => [ + 'ability-missingAbilityWithMessage' => true, + ], + ], + [ + [ + 'missing' => 'missingAbilityWithMessage', + 'type' => 'missing-ability', + // phpcs:ignore Generic.Files.LineLength.TooLong + 'msg' => 'FakeExtension requires "missingAbilityWithMessage" ability: Missing.', + ], + ], + ], + [ + [ + 'platform' => [ + 'ability-missingAbility' => false, + ], + ], + [], + ], + [ + [ + 'platform' => [ + 'ability-missingAbilityWithMessage' => false, + ], + ], + [], + ], + ]; + } + + /** + * Check, if a non-parsable version constraint does not throw an exception or + * returns any error message. + */ + public function testInvalidConstraint() { + $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); + $checker->setLoadedExtensionsAndSkins( [ + 'FakeDependency' => [ + 'version' => 'not really valid', + ], + ] ); + $this->assertEquals( [ + [ + 'type' => 'invalid-version', + 'msg' => "FakeDependency does not have a valid version string.", + ], + ], $checker->checkArray( [ + 'FakeExtension' => [ + 'extensions' => [ + 'FakeDependency' => '1.24.3', + ], + ], + ] ) ); + + $checker = new VersionChecker( '1.0.0', '7.0.0', [] ); + $checker->setLoadedExtensionsAndSkins( [ + 'FakeDependency' => [ + 'version' => '1.24.3', + ], + ] ); + + $this->setExpectedException( UnexpectedValueException::class ); + $checker->checkArray( [ + 'FakeExtension' => [ + 'FakeDependency' => 'not really valid', + ], + ] ); + } + + public function provideInvalidDependency() { + return [ + [ + [ + 'FakeExtension' => [ + 'platform' => [ + 'undefinedPlatformDependency' => '*', + ], + ], + ], + 'undefinedPlatformDependency', + ], + [ + [ + 'FakeExtension' => [ + 'platform' => [ + 'phpLoadedExtension' => '*', + ], + ], + ], + 'phpLoadedExtension', + ], + [ + [ + 'FakeExtension' => [ + 'platform' => [ + 'ability-invalidAbility' => true, + ], + ], + ], + 'ability-invalidAbility', + ], + [ + [ + 'FakeExtension' => [ + 'platform' => [ + 'presentAbility' => true, + ], + ], + ], + 'presentAbility', + ], + [ + [ + 'FakeExtension' => [ + 'undefinedDependencyType' => '*', + ], + ], + 'undefinedDependencyType', + ], + // T197478 + [ + [ + 'FakeExtension' => [ + 'skin' => [ + 'FakeSkin' => '*', + ], + ], + ], + 'skin', + ], + ]; + } + + /** + * @dataProvider provideInvalidDependency + */ + public function testInvalidDependency( $depencency, $type ) { + $checker = new VersionChecker( + '1.0.0', + '7.0.0', + [ 'phpLoadedExtension' ], + [ + 'presentAbility' => true, + 'missingAbility' => false, + ] + ); + $this->setExpectedException( + UnexpectedValueException::class, + "Dependency type $type unknown in FakeExtension" + ); + $checker->checkArray( $depencency ); + } + + public function testInvalidPhpExtensionConstraint() { + $checker = new VersionChecker( '1.0.0', '7.0.0', [ 'phpLoadedExtension' ] ); + $this->setExpectedException( + UnexpectedValueException::class, + 'Version constraints for PHP extensions are not supported in FakeExtension' + ); + $checker->checkArray( [ + 'FakeExtension' => [ + 'platform' => [ + 'ext-phpLoadedExtension' => '1.0.0', + ], + ], + ] ); + } + + /** + * @dataProvider provideInvalidAbilityType + */ + public function testInvalidAbilityType( $value ) { + $checker = new VersionChecker( '1.0.0', '7.0.0', [], [ 'presentAbility' => true ] ); + $this->setExpectedException( + UnexpectedValueException::class, + 'Only booleans are allowed to to indicate the presence of abilities in FakeExtension' + ); + $checker->checkArray( [ + 'FakeExtension' => [ + 'platform' => [ + 'ability-presentAbility' => $value, + ], + ], + ] ); + } + + public function provideInvalidAbilityType() { + return [ + [ null ], + [ 1 ], + [ '1' ], + ]; + } + +} diff --git a/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php new file mode 100644 index 0000000000..e178e96f41 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php @@ -0,0 +1,138 @@ + 'qqx', + 'modules' => 'test.default', + 'only' => 'scripts', + 'skin' => 'fallback', + 'target' => 'test', + ] ); + return new ResourceLoaderContext( + new ResourceLoader( ResourceLoaderTestCase::getMinimalConfig() ), + $request + ); + } + + public function testChangeModules() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getModules(), [ 'test.default' ], 'inherit from parent' ); + + $derived->setModules( [ 'test.override' ] ); + $this->assertSame( $derived->getModules(), [ 'test.override' ] ); + } + + public function testChangeLanguageAndDirection() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getLanguage(), 'qqx', 'inherit from parent' ); + $this->assertSame( $derived->getDirection(), 'ltr', 'inherit from parent' ); + + $derived->setLanguage( 'nl' ); + $this->assertSame( $derived->getLanguage(), 'nl' ); + $this->assertSame( $derived->getDirection(), 'ltr' ); + + // Changing the language must clear cache of computed direction + $derived->setLanguage( 'he' ); + $this->assertSame( $derived->getDirection(), 'rtl' ); + $this->assertSame( $derived->getLanguage(), 'he' ); + + // Overriding the direction explicitly is allowed + $derived->setDirection( 'ltr' ); + $this->assertSame( $derived->getDirection(), 'ltr' ); + $this->assertSame( $derived->getLanguage(), 'he' ); + } + + public function testChangeSkin() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getSkin(), 'fallback', 'inherit from parent' ); + + $derived->setSkin( 'myskin' ); + $this->assertSame( $derived->getSkin(), 'myskin' ); + } + + public function testChangeUser() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getUser(), null, 'inherit from parent' ); + + $derived->setUser( 'MyUser' ); + $this->assertSame( $derived->getUser(), 'MyUser' ); + } + + public function testChangeDebug() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getDebug(), false, 'inherit from parent' ); + + $derived->setDebug( true ); + $this->assertSame( $derived->getDebug(), true ); + } + + public function testChangeOnly() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getOnly(), 'scripts', 'inherit from parent' ); + + $derived->setOnly( 'styles' ); + $this->assertSame( $derived->getOnly(), 'styles' ); + + $derived->setOnly( null ); + $this->assertSame( $derived->getOnly(), null ); + } + + public function testChangeVersion() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getVersion(), null ); + + $derived->setVersion( 'hw1' ); + $this->assertSame( $derived->getVersion(), 'hw1' ); + } + + public function testChangeRaw() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getRaw(), false, 'inherit from parent' ); + + $derived->setRaw( true ); + $this->assertSame( $derived->getRaw(), true ); + } + + public function testChangeHash() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertSame( $derived->getHash(), 'qqx|fallback|||scripts|||||', 'inherit' ); + + $derived->setLanguage( 'nl' ); + $derived->setUser( 'Example' ); + // Assert that subclass is able to clear parent class "hash" member + $this->assertSame( $derived->getHash(), 'nl|fallback||Example|scripts|||||' ); + } + + public function testChangeContentOverrides() { + $derived = new DerivativeResourceLoaderContext( self::makeContext() ); + $this->assertNull( $derived->getContentOverrideCallback(), 'default' ); + + $override = function ( Title $t ) { + return null; + }; + $derived->setContentOverrideCallback( $override ); + $this->assertSame( $override, $derived->getContentOverrideCallback(), 'changed' ); + + $derived2 = new DerivativeResourceLoaderContext( $derived ); + $this->assertSame( + $override, + $derived2->getContentOverrideCallback(), + 'change via a second derivative layer' + ); + } + + public function testImmutableAccessors() { + $context = self::makeContext(); + $derived = new DerivativeResourceLoaderContext( $context ); + $this->assertSame( $derived->getRequest(), $context->getRequest() ); + $this->assertSame( $derived->getResourceLoader(), $context->getResourceLoader() ); + } +} diff --git a/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php new file mode 100644 index 0000000000..e094d92b9d --- /dev/null +++ b/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php @@ -0,0 +1,197 @@ +wanCache = new WANObjectCache( [ + 'cache' => new HashBagOStuff() + ] ); + + $this->clock = 1301655600.000; + $this->wanCache->setMockTime( $this->clock ); + } + + public function testBlobCreation() { + $module = $this->makeModule( [ 'mainpage' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + + $blobStore = $this->makeBlobStore( null, $rl ); + $blob = $blobStore->getBlob( $module, 'en' ); + + $this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' ); + } + + public function testBlobCreation_empty() { + $module = $this->makeModule( [] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + + $blobStore = $this->makeBlobStore( null, $rl ); + $blob = $blobStore->getBlob( $module, 'en' ); + + $this->assertEquals( '{}', $blob, 'Generated blob' ); + } + + public function testBlobCreation_unknownMessage() { + $module = $this->makeModule( [ 'i-dont-exist', 'mainpage', 'i-dont-exist2' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + $blobStore = $this->makeBlobStore( null, $rl ); + + // Generating a blob should continue without errors, + // with keys of unknown messages excluded from the blob. + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' ); + } + + public function testMessageCachingAndPurging() { + $module = $this->makeModule( [ 'example' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); + + // Advance this new WANObjectCache instance to a normal state, + // by doing one "get" and letting its hold off period expire. + // Without this, the first real "get" would lazy-initialise the + // checkKey and thus reject the first "set". + $blobStore->getBlob( $module, 'en' ); + $this->clock += 20; + + // Arrange version 1 of a message + $blobStore->expects( $this->once() ) + ->method( 'fetchMessage' ) + ->will( $this->returnValue( 'First version' ) ); + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"First version"}', $blob, 'Blob for v1' ); + + // Arrange version 2 + $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); + $blobStore->expects( $this->once() ) + ->method( 'fetchMessage' ) + ->will( $this->returnValue( 'Second version' ) ); + $this->clock += 20; + + // Assert + // We do not validate whether a cached message is up-to-date. + // Instead, changes to messages will send us a purge. + // When cache is not purged or expired, it must be used. + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"First version"}', $blob, 'Reuse cached v1 blob' ); + + // Purge cache + $blobStore->updateMessage( 'example' ); + $this->clock += 20; + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"Second version"}', $blob, 'Updated blob for v2' ); + } + + public function testPurgeEverything() { + $module = $this->makeModule( [ 'example' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); + // Advance this new WANObjectCache instance to a normal state. + $blobStore->getBlob( $module, 'en' ); + $this->clock += 20; + + // Arrange version 1 and 2 + $blobStore->expects( $this->exactly( 2 ) ) + ->method( 'fetchMessage' ) + ->will( $this->onConsecutiveCalls( 'First', 'Second' ) ); + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"First"}', $blob, 'Blob for v1' ); + + $this->clock += 20; + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"First"}', $blob, 'Blob for v1 again' ); + + // Purge everything + $blobStore->clear(); + $this->clock += 20; + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"example":"Second"}', $blob, 'Blob for v2' ); + } + + public function testValidateAgainstModuleRegistry() { + // Arrange version 1 of a module + $module = $this->makeModule( [ 'foo' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); + $blobStore->expects( $this->once() ) + ->method( 'fetchMessage' ) + ->will( $this->returnValueMap( [ + // message key, language code, message value + [ 'foo', 'en', 'Hello' ], + ] ) ); + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"foo":"Hello"}', $blob, 'Blob for v1' ); + + // Arrange version 2 of module + // While message values may be out of date, the set of messages returned + // must always match the set of message keys required by the module. + // We do not receive purges for this because no messages were changed. + $module = $this->makeModule( [ 'foo', 'bar' ] ); + $rl = new EmptyResourceLoader(); + $rl->register( $module->getName(), $module ); + $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl ); + $blobStore->expects( $this->exactly( 2 ) ) + ->method( 'fetchMessage' ) + ->will( $this->returnValueMap( [ + // message key, language code, message value + [ 'foo', 'en', 'Hello' ], + [ 'bar', 'en', 'World' ], + ] ) ); + + // Assert + $blob = $blobStore->getBlob( $module, 'en' ); + $this->assertEquals( '{"foo":"Hello","bar":"World"}', $blob, 'Blob for v2' ); + } + + public function testSetLoggedIsVoid() { + $blobStore = $this->makeBlobStore(); + $this->assertSame( null, $blobStore->setLogger( new Psr\Log\NullLogger() ) ); + } + + private function makeBlobStore( $methods = null, $rl = null ) { + $blobStore = $this->getMockBuilder( MessageBlobStore::class ) + ->setConstructorArgs( [ $rl ?? $this->createMock( ResourceLoader::class ) ] ) + ->setMethods( $methods ) + ->getMock(); + + $access = TestingAccessWrapper::newFromObject( $blobStore ); + $access->wanCache = $this->wanCache; + return $blobStore; + } + + private function makeModule( array $messages ) { + $module = new ResourceLoaderTestModule( [ 'messages' => $messages ] ); + $module->setName( 'test.blobstore' ); + return $module; + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php new file mode 100644 index 0000000000..03a3e24ad3 --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php @@ -0,0 +1,434 @@ +getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context ); + $client->setModules( [ + 'test', + 'test.private', + 'test.shouldembed.empty', + 'test.shouldembed', + 'test.user', + 'test.unregistered', + ] ); + $client->setModuleStyles( [ + 'test.styles.mixed', + 'test.styles.user.empty', + 'test.styles.private', + 'test.styles.pure', + 'test.styles.shouldembed', + 'test.styles.deprecated', + 'test.unregistered.styles', + ] ); + + $expected = [ + 'states' => [ + // The below are NOT queued for loading via `mw.loader.load(Array)`. + // Instead we tell the client to set their state to "loading" so that + // if they are needed as dependencies, the client will not try to + // load them on-demand, because the server is taking care of them already. + // Either: + // - Embedded as inline scripts in the HTML (e.g. user-private code, and + // previews). Once that script tag is reached, the state is "loaded". + // - Loaded directly from the HTML with a dedicated HTTP request (e.g. + // user scripts, which vary by a 'user' and 'version' parameter that + // the static user-agnostic startup module won't have). + 'test.private' => 'loading', + 'test.shouldembed' => 'loading', + 'test.user' => 'loading', + // The below are known to the server to be empty scripts, or to be + // synchronously loaded stylesheets. These start in the "ready" state. + 'test.shouldembed.empty' => 'ready', + 'test.styles.pure' => 'ready', + 'test.styles.user.empty' => 'ready', + 'test.styles.private' => 'ready', + 'test.styles.shouldembed' => 'ready', + 'test.styles.deprecated' => 'ready', + ], + 'general' => [ + 'test', + ], + 'styles' => [ + 'test.styles.pure', + 'test.styles.deprecated', + ], + 'embed' => [ + 'styles' => [ 'test.styles.private', 'test.styles.shouldembed' ], + 'general' => [ + 'test.private', + 'test.shouldembed', + 'test.user', + ], + ], + 'styleDeprecations' => [ + Xml::encodeJsCall( + 'mw.log.warn', + [ 'This page is using the deprecated ResourceLoader module "test.styles.deprecated". +Deprecation message.' ] + ) + ], + ]; + + $access = TestingAccessWrapper::newFromObject( $client ); + $this->assertEquals( $expected, $access->getData() ); + } + + public function testGetHeadHtml() { + $context = self::makeContext(); + $context->getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context, [ + 'nonce' => false, + ] ); + $client->setConfig( [ 'key' => 'value' ] ); + $client->setModules( [ + 'test', + 'test.private', + ] ); + $client->setModuleStyles( [ + 'test.styles.pure', + 'test.styles.private', + 'test.styles.deprecated', + ] ); + $client->setExemptStates( [ + 'test.exempt' => 'ready', + ] ); + + // phpcs:disable Generic.Files.LineLength + $expected = '' . "\n" + . '' . "\n" + . '' . "\n" + . '' . "\n" + . ''; + // phpcs:enable + $expected = self::expandVariables( $expected ); + + $this->assertSame( $expected, (string)$client->getHeadHtml() ); + } + + /** + * Confirm that 'target' is passed down to the startup module's load url. + */ + public function testGetHeadHtmlWithTarget() { + $client = new ResourceLoaderClientHtml( + self::makeContext(), + [ 'target' => 'example' ] + ); + + // phpcs:disable Generic.Files.LineLength + $expected = '' . "\n" + . ''; + // phpcs:enable + + $this->assertSame( $expected, (string)$client->getHeadHtml() ); + } + + /** + * Confirm that 'safemode' is passed down to startup. + */ + public function testGetHeadHtmlWithSafemode() { + $client = new ResourceLoaderClientHtml( + self::makeContext(), + [ 'safemode' => '1' ] + ); + + // phpcs:disable Generic.Files.LineLength + $expected = '' . "\n" + . ''; + // phpcs:enable + + $this->assertSame( $expected, (string)$client->getHeadHtml() ); + } + + /** + * Confirm that a null 'target' is the same as no target. + */ + public function testGetHeadHtmlWithNullTarget() { + $client = new ResourceLoaderClientHtml( + self::makeContext(), + [ 'target' => null ] + ); + + // phpcs:disable Generic.Files.LineLength + $expected = '' . "\n" + . ''; + // phpcs:enable + + $this->assertSame( $expected, (string)$client->getHeadHtml() ); + } + + public function testGetBodyHtml() { + $context = self::makeContext(); + $context->getResourceLoader()->register( self::makeSampleModules() ); + + $client = new ResourceLoaderClientHtml( $context, [ 'nonce' => false ] ); + $client->setConfig( [ 'key' => 'value' ] ); + $client->setModules( [ + 'test', + 'test.private.bottom', + ] ); + $client->setModuleStyles( [ + 'test.styles.deprecated', + ] ); + // phpcs:disable Generic.Files.LineLength + $expected = ''; + // phpcs:enable + + $this->assertSame( $expected, (string)$client->getBodyHtml() ); + } + + public static function provideMakeLoad() { + // phpcs:disable Generic.Files.LineLength + return [ + [ + 'context' => [], + 'modules' => [ 'test.unknown' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.private' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.private' ], + 'only' => ResourceLoaderModule::TYPE_COMBINED, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + // Eg. startup module + 'modules' => [ 'test.scripts.raw' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.scripts.raw' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'extra' => [ 'sync' => '1' ], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.scripts.user' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.user' ], + 'only' => ResourceLoaderModule::TYPE_COMBINED, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [ 'debug' => 'true' ], + 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '' . "\n" + . '', + ], + [ + 'context' => [ 'debug' => 'false' ], + 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.noscript' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.shouldembed' ], + 'only' => ResourceLoaderModule::TYPE_COMBINED, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.shouldembed' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.scripts.shouldembed' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test', 'test.shouldembed' ], + 'only' => ResourceLoaderModule::TYPE_COMBINED, + 'extra' => [], + 'output' => '', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.pure', 'test.styles.shouldembed' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => + '' . "\n" + . '' + ], + [ + 'context' => [], + 'modules' => [ 'test.ordering.a', 'test.ordering.e', 'test.ordering.b', 'test.ordering.d', 'test.ordering.c' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'extra' => [], + 'output' => + '' . "\n" + . '' . "\n" + . '' + ], + ]; + // phpcs:enable + } + + /** + * @dataProvider provideMakeLoad + * @covers ResourceLoaderClientHtml + * @covers ResourceLoaderModule::getModuleContent + * @covers ResourceLoader + */ + public function testMakeLoad( + array $contextQuery, + array $modules, + $type, + array $extraQuery, + $expected + ) { + $context = self::makeContext( $contextQuery ); + $context->getResourceLoader()->register( self::makeSampleModules() ); + $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery, false ); + $expected = self::expandVariables( $expected ); + $this->assertSame( $expected, (string)$actual ); + } + + public function testGetDocumentAttributes() { + $client = new ResourceLoaderClientHtml( self::makeContext() ); + $this->assertInternalType( 'array', $client->getDocumentAttributes() ); + } + + private static function expandVariables( $text ) { + return strtr( $text, [ + '{blankVer}' => ResourceLoaderTestCase::BLANK_VERSION + ] ); + } + + private static function makeContext( $extraQuery = [] ) { + $conf = new HashConfig( [ + 'ResourceModuleSkinStyles' => [], + 'ResourceModules' => [], + 'EnableJavaScriptTest' => false, + 'LoadScript' => '/w/load.php', + ] ); + return new ResourceLoaderContext( + new ResourceLoader( $conf ), + new FauxRequest( array_merge( [ + 'lang' => 'nl', + 'skin' => 'fallback', + 'user' => 'Example', + 'target' => 'phpunit', + ], $extraQuery ) ) + ); + } + + private static function makeModule( array $options = [] ) { + return new ResourceLoaderTestModule( $options ); + } + + private static function makeSampleModules() { + $modules = [ + 'test' => [], + 'test.private' => [ 'group' => 'private' ], + 'test.shouldembed.empty' => [ 'shouldEmbed' => true, 'isKnownEmpty' => true ], + 'test.shouldembed' => [ 'shouldEmbed' => true ], + 'test.user' => [ 'group' => 'user' ], + + 'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ], + 'test.styles.mixed' => [], + 'test.styles.noscript' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'group' => 'noscript', + ], + 'test.styles.user' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'group' => 'user', + ], + 'test.styles.user.empty' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'group' => 'user', + 'isKnownEmpty' => true, + ], + 'test.styles.private' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'group' => 'private', + 'styles' => '.private{}', + ], + 'test.styles.shouldembed' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'shouldEmbed' => true, + 'styles' => '.shouldembed{}', + ], + 'test.styles.deprecated' => [ + 'type' => ResourceLoaderModule::LOAD_STYLES, + 'deprecated' => 'Deprecation message.', + ], + + 'test.scripts' => [], + 'test.scripts.user' => [ 'group' => 'user' ], + 'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ], + 'test.scripts.raw' => [ 'isRaw' => true ], + 'test.scripts.shouldembed' => [ 'shouldEmbed' => true ], + + 'test.ordering.a' => [ 'shouldEmbed' => false ], + 'test.ordering.b' => [ 'shouldEmbed' => false ], + 'test.ordering.c' => [ 'shouldEmbed' => true, 'styles' => '.orderingC{}' ], + 'test.ordering.d' => [ 'shouldEmbed' => true, 'styles' => '.orderingD{}' ], + 'test.ordering.e' => [ 'shouldEmbed' => false ], + ]; + return array_map( function ( $options ) { + return self::makeModule( $options ); + }, $modules ); + } +} diff --git a/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php new file mode 100644 index 0000000000..2ec8ea987a --- /dev/null +++ b/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php @@ -0,0 +1,122 @@ + false, + 'LoadScript' => '/w/load.php', + // For ResourceLoader::register() + 'ResourceModuleSkinStyles' => [], + ] ) ); + } + + public function testEmpty() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + + // Request parameters + $this->assertEquals( [], $ctx->getModules() ); + $this->assertEquals( 'qqx', $ctx->getLanguage() ); + $this->assertEquals( false, $ctx->getDebug() ); + $this->assertEquals( null, $ctx->getOnly() ); + $this->assertEquals( 'fallback', $ctx->getSkin() ); + $this->assertEquals( null, $ctx->getUser() ); + $this->assertNull( $ctx->getContentOverrideCallback() ); + + // Misc + $this->assertEquals( 'ltr', $ctx->getDirection() ); + $this->assertEquals( 'qqx|fallback||||||||', $ctx->getHash() ); + $this->assertInstanceOf( User::class, $ctx->getUserObj() ); + } + + public function testDummy() { + $this->assertInstanceOf( + ResourceLoaderContext::class, + ResourceLoaderContext::newDummyContext() + ); + } + + public function testAccessors() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertInstanceOf( ResourceLoader::class, $ctx->getResourceLoader() ); + $this->assertInstanceOf( Config::class, $ctx->getConfig() ); + $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() ); + $this->assertInstanceOf( Psr\Log\LoggerInterface::class, $ctx->getLogger() ); + } + + public function testTypicalRequest() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'debug' => 'false', + 'lang' => 'zh', + 'modules' => 'foo|foo.quux,baz,bar|baz.quux', + 'only' => 'styles', + 'skin' => 'fallback', + ] ) ); + + // Request parameters + $this->assertEquals( + $ctx->getModules(), + [ 'foo', 'foo.quux', 'foo.baz', 'foo.bar', 'baz.quux' ] + ); + $this->assertEquals( false, $ctx->getDebug() ); + $this->assertEquals( 'zh', $ctx->getLanguage() ); + $this->assertEquals( 'styles', $ctx->getOnly() ); + $this->assertEquals( 'fallback', $ctx->getSkin() ); + $this->assertEquals( null, $ctx->getUser() ); + + // Misc + $this->assertEquals( 'ltr', $ctx->getDirection() ); + $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() ); + } + + public function testShouldInclude() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' ); + $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in combined' ); + $this->assertTrue( $ctx->shouldIncludeMessages(), 'Messages in combined' ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'only' => 'styles' + ] ) ); + $this->assertFalse( $ctx->shouldIncludeScripts(), 'Scripts not in styles-only' ); + $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in styles-only' ); + $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in styles-only' ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'only' => 'scripts' + ] ) ); + $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in scripts-only' ); + $this->assertFalse( $ctx->shouldIncludeStyles(), 'Styles not in scripts-only' ); + $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in scripts-only' ); + } + + public function testGetUser() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) ); + $this->assertSame( null, $ctx->getUser() ); + $this->assertTrue( $ctx->getUserObj()->isAnon() ); + + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'user' => 'Example' + ] ) ); + $this->assertSame( 'Example', $ctx->getUser() ); + $this->assertEquals( 'Example', $ctx->getUserObj()->getName() ); + } + + public function testMsg() { + $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [ + 'lang' => 'en' + ] ) ); + $msg = $ctx->msg( 'mainpage' ); + $this->assertInstanceOf( Message::class, $msg ); + $this->assertSame( 'Main Page', $msg->useDatabase( false )->plain() ); + } +} diff --git a/tests/phpunit/includes/search/SearchIndexFieldTest.php b/tests/phpunit/includes/search/SearchIndexFieldTest.php new file mode 100644 index 0000000000..8b4119e0d3 --- /dev/null +++ b/tests/phpunit/includes/search/SearchIndexFieldTest.php @@ -0,0 +1,56 @@ +getMockBuilder( SearchIndexFieldDefinition::class ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n1, $t1 ] ) + ->getMock(); + $field2 = + $this->getMockBuilder( SearchIndexFieldDefinition::class ) + ->setMethods( [ 'getMapping' ] ) + ->setConstructorArgs( [ $n2, $t2 ] ) + ->getMock(); + + if ( $result ) { + $this->assertNotFalse( $field1->merge( $field2 ) ); + } else { + $this->assertFalse( $field1->merge( $field2 ) ); + } + + $field1->setFlag( 0xFF ); + $this->assertFalse( $field1->merge( $field2 ) ); + + $field1->setMergeCallback( + function ( $a, $b ) { + return "test"; + } + ); + $this->assertEquals( "test", $field1->merge( $field2 ) ); + } + +} diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php new file mode 100644 index 0000000000..02fa5e9cac --- /dev/null +++ b/tests/phpunit/includes/search/SearchSuggestionSetTest.php @@ -0,0 +1,111 @@ +assertEquals( 0, $set->getSize() ); + $set->append( new SearchSuggestion( 3 ) ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + + $suggestion = new SearchSuggestion( 4 ); + $set->append( $suggestion ); + $this->assertEquals( 2, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + $this->assertEquals( 2, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 2 ); + $set->append( $suggestion ); + $this->assertEquals( 1, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + $this->assertEquals( 1, $suggestion->getScore() ); + + $scores = $set->map( function ( $s ) { + return $s->getScore(); + } ); + $sorted = $scores; + asort( $sorted ); + $this->assertEquals( $sorted, $scores ); + } + + /** + * Test that adding a new best suggestion will keep proper score + * ordering + * @covers SearchSuggestionSet::getWorstScore + * @covers SearchSuggestionSet::getBestScore + * @covers SearchSuggestionSet::prepend + */ + public function testInsertBest() { + $set = SearchSuggestionSet::emptySuggestionSet(); + $this->assertEquals( 0, $set->getSize() ); + $set->prepend( new SearchSuggestion( 3 ) ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 3, $set->getBestScore() ); + + $suggestion = new SearchSuggestion( 4 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 4, $set->getBestScore() ); + $this->assertEquals( 4, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 0 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 5, $set->getBestScore() ); + $this->assertEquals( 5, $suggestion->getScore() ); + + $suggestion = new SearchSuggestion( 2 ); + $set->prepend( $suggestion ); + $this->assertEquals( 3, $set->getWorstScore() ); + $this->assertEquals( 6, $set->getBestScore() ); + $this->assertEquals( 6, $suggestion->getScore() ); + + $scores = $set->map( function ( $s ) { + return $s->getScore(); + } ); + $sorted = $scores; + asort( $sorted ); + $this->assertEquals( $sorted, $scores ); + } + + /** + * @covers SearchSuggestionSet::shrink + */ + public function testShrink() { + $set = SearchSuggestionSet::emptySuggestionSet(); + for ( $i = 0; $i < 100; $i++ ) { + $set->append( new SearchSuggestion( 0 ) ); + } + $set->shrink( 10 ); + $this->assertEquals( 10, $set->getSize() ); + + $set->shrink( 0 ); + $this->assertEquals( 0, $set->getSize() ); + } + + // TODO: test for fromTitles +} diff --git a/tests/phpunit/includes/session/MetadataMergeExceptionTest.php b/tests/phpunit/includes/session/MetadataMergeExceptionTest.php new file mode 100644 index 0000000000..8cb4302a4e --- /dev/null +++ b/tests/phpunit/includes/session/MetadataMergeExceptionTest.php @@ -0,0 +1,30 @@ + 'bar' ]; + + $ex = new MetadataMergeException(); + $this->assertInstanceOf( \UnexpectedValueException::class, $ex ); + $this->assertSame( [], $ex->getContext() ); + + $ex2 = new MetadataMergeException( 'Message', 42, $ex, $data ); + $this->assertSame( 'Message', $ex2->getMessage() ); + $this->assertSame( 42, $ex2->getCode() ); + $this->assertSame( $ex, $ex2->getPrevious() ); + $this->assertSame( $data, $ex2->getContext() ); + + $ex->setContext( $data ); + $this->assertSame( $data, $ex->getContext() ); + } + +} diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php new file mode 100644 index 0000000000..2b06d971a6 --- /dev/null +++ b/tests/phpunit/includes/session/SessionIdTest.php @@ -0,0 +1,22 @@ +assertSame( 'foo', $id->getId() ); + $this->assertSame( 'foo', (string)$id ); + $id->setId( 'bar' ); + $this->assertSame( 'bar', $id->getId() ); + $this->assertSame( 'bar', (string)$id ); + } + +} diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php new file mode 100644 index 0000000000..8f7b2a6e70 --- /dev/null +++ b/tests/phpunit/includes/session/SessionInfoTest.php @@ -0,0 +1,356 @@ +fail( 'Expected exception not thrown', 'priority < min' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' ); + } + + try { + new SessionInfo( SessionInfo::MAX_PRIORITY + 1, [] ); + $this->fail( 'Expected exception not thrown', 'priority > max' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'id' => 'ABC?' ] ); + $this->fail( 'Expected exception not thrown', 'bad session ID' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'userInfo' => new \stdClass ] ); + $this->fail( 'Expected exception not thrown', 'bad userInfo' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, [] ); + $this->fail( 'Expected exception not thrown', 'no provider, no id' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(), + 'no provider, no id' ); + } + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, [ 'copyFrom' => new \stdClass ] ); + $this->fail( 'Expected exception not thrown', 'bad copyFrom' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid copyFrom', $ex->getMessage(), + 'bad copyFrom' ); + } + + $manager = new SessionManager(); + $provider = $this->getMockBuilder( SessionProvider::class ) + ->setMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] ) + ->getMockForAbstractClass(); + $provider->setManager( $manager ); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock' ) ); + + $provider2 = $this->getMockBuilder( SessionProvider::class ) + ->setMethods( [ 'persistsSessionId', 'canChangeUser', '__toString' ] ) + ->getMockForAbstractClass(); + $provider2->setManager( $manager ); + $provider2->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider2->expects( $this->any() )->method( '__toString' ) + ->will( $this->returnValue( 'Mock2' ) ); + + try { + new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'userInfo' => $anonInfo, + 'metadata' => 'foo', + ] ); + $this->fail( 'Expected exception not thrown', 'bad metadata' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' ); + } + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'userInfo' => $anonInfo + ] ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'userInfo' => $unverifiedUserInfo, + 'metadata' => [ 'Foo' ], + ] ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertSame( [ 'Foo' ], $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'userInfo' => $userInfo + ] ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertNotNull( $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $id = $manager->generateSessionId(); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $anonInfo + ] ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $anonInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'userInfo' => $userInfo + ] ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo, + 'metadata' => [ 'Foo' ], + ] ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'id' => $id, + 'remembered' => true, + 'userInfo' => $userInfo, + ] ); + $this->assertFalse( $info->wasRemembered(), 'no provider' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + ] ); + $this->assertFalse( $info->wasRemembered(), 'no user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $anonInfo, + ] ); + $this->assertFalse( $info->wasRemembered(), 'anonymous user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'remembered' => true, + 'userInfo' => $unverifiedUserInfo, + ] ); + $this->assertFalse( $info->wasRemembered(), 'unverified user' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'remembered' => false, + 'userInfo' => $userInfo, + ] ); + $this->assertFalse( $info->wasRemembered(), 'specific override' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'id' => $id, + 'idIsSafe' => true, + ] ); + $this->assertSame( $id, $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() ); + $this->assertTrue( $info->isIdSafe() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'id' => $id, + 'forceUse' => true, + ] ); + $this->assertFalse( $info->forceUse(), 'no provider' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'forceUse' => true, + ] ); + $this->assertFalse( $info->forceUse(), 'no id' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, [ + 'provider' => $provider, + 'id' => $id, + 'forceUse' => true, + ] ); + $this->assertTrue( $info->forceUse(), 'correct use' ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'id' => $id, + 'forceHTTPS' => 1, + ] ); + $this->assertTrue( $info->forceHTTPS() ); + + $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'id' => $id . 'A', + 'provider' => $provider, + 'userInfo' => $userInfo, + 'idIsSafe' => true, + 'forceUse' => true, + 'persisted' => true, + 'remembered' => true, + 'forceHTTPS' => true, + 'metadata' => [ 'foo!' ], + ] ); + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [ + 'copyFrom' => $fromInfo, + ] ); + $this->assertSame( $id . 'A', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider, $info->getProvider() ); + $this->assertSame( $userInfo, $info->getUserInfo() ); + $this->assertTrue( $info->isIdSafe() ); + $this->assertTrue( $info->forceUse() ); + $this->assertTrue( $info->wasPersisted() ); + $this->assertTrue( $info->wasRemembered() ); + $this->assertTrue( $info->forceHTTPS() ); + $this->assertSame( [ 'foo!' ], $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, [ + 'id' => $id . 'X', + 'provider' => $provider2, + 'userInfo' => $unverifiedUserInfo, + 'idIsSafe' => false, + 'forceUse' => false, + 'persisted' => false, + 'remembered' => false, + 'forceHTTPS' => false, + 'metadata' => null, + 'copyFrom' => $fromInfo, + ] ); + $this->assertSame( $id . 'X', $info->getId() ); + $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() ); + $this->assertSame( $provider2, $info->getProvider() ); + $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() ); + $this->assertFalse( $info->isIdSafe() ); + $this->assertFalse( $info->forceUse() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertFalse( $info->wasRemembered() ); + $this->assertFalse( $info->forceHTTPS() ); + $this->assertNull( $info->getProviderMetadata() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'id' => $id, + ] ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]null$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $userInfo + ] ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'provider' => $provider, + 'id' => $id, + 'persisted' => true, + 'userInfo' => $unverifiedUserInfo + ] ); + $this->assertSame( + '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id", + (string)$info, + 'toString' + ); + } + + public function testCompare() { + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, [ 'id' => $id ] ); + $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, [ 'id' => $id ] ); + + $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' ); + $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' ); + $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' ); + } +} diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php new file mode 100644 index 0000000000..6ff6a97b8f --- /dev/null +++ b/tests/phpunit/includes/session/SessionProviderTest.php @@ -0,0 +1,206 @@ +getMockForAbstractClass( SessionProvider::class ); + $priv = TestingAccessWrapper::newFromObject( $provider ); + + $provider->setConfig( $config ); + $this->assertSame( $config, $priv->config ); + $provider->setLogger( $logger ); + $this->assertSame( $logger, $priv->logger ); + $provider->setManager( $manager ); + $this->assertSame( $manager, $priv->manager ); + $this->assertSame( $manager, $provider->getManager() ); + + $provider->invalidateSessionsForUser( new \User ); + + $this->assertSame( [], $provider->getVaryHeaders() ); + $this->assertSame( [], $provider->getVaryCookies() ); + $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) ); + + $this->assertSame( get_class( $provider ), (string)$provider ); + + $this->assertNull( $provider->getRememberUserDuration() ); + + $this->assertNull( $provider->whyNoSession() ); + + $info = new SessionInfo( SessionInfo::MIN_PRIORITY, [ + 'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'provider' => $provider, + ] ); + $metadata = [ 'foo' ]; + $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) ); + $this->assertSame( [ 'foo' ], $metadata ); + } + + /** + * @dataProvider provideNewSessionInfo + * @param bool $persistId Return value for ->persistsSessionId() + * @param bool $persistUser Return value for ->persistsSessionUser() + * @param bool $ok Whether a SessionInfo is provided + */ + public function testNewSessionInfo( $persistId, $persistUser, $ok ) { + $manager = new SessionManager(); + + $provider = $this->getMockBuilder( SessionProvider::class ) + ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'persistsSessionId' ) + ->will( $this->returnValue( $persistId ) ); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( $persistUser ) ); + $provider->setManager( $manager ); + + if ( $ok ) { + $info = $provider->newSessionInfo(); + $this->assertNotNull( $info ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + + $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $info = $provider->newSessionInfo( $id ); + $this->assertNotNull( $info ); + $this->assertSame( $id, $info->getId() ); + $this->assertFalse( $info->wasPersisted() ); + $this->assertTrue( $info->isIdSafe() ); + } else { + $this->assertNull( $provider->newSessionInfo() ); + } + } + + public function testMergeMetadata() { + $provider = $this->getMockBuilder( SessionProvider::class ) + ->getMockForAbstractClass(); + + try { + $provider->mergeMetadata( + [ 'foo' => 1, 'baz' => 3 ], + [ 'bar' => 2, 'baz' => '3' ] + ); + $this->fail( 'Expected exception not thrown' ); + } catch ( MetadataMergeException $ex ) { + $this->assertSame( 'Key "baz" changed', $ex->getMessage() ); + $this->assertSame( + [ 'old_value' => 3, 'new_value' => '3' ], $ex->getContext() ); + } + + $res = $provider->mergeMetadata( + [ 'foo' => 1, 'baz' => 3 ], + [ 'bar' => 2, 'baz' => 3 ] + ); + $this->assertSame( [ 'bar' => 2, 'baz' => 3 ], $res ); + } + + public static function provideNewSessionInfo() { + return [ + [ false, false, false ], + [ true, false, false ], + [ false, true, false ], + [ true, true, true ], + ]; + } + + public function testImmutableSessions() { + $provider = $this->getMockBuilder( SessionProvider::class ) + ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( true ) ); + $provider->preventSessionsForUser( 'Foo' ); + + $provider = $this->getMockBuilder( SessionProvider::class ) + ->setMethods( [ 'canChangeUser', 'persistsSessionId' ] ) + ->getMockForAbstractClass(); + $provider->expects( $this->any() )->method( 'canChangeUser' ) + ->will( $this->returnValue( false ) ); + try { + $provider->preventSessionsForUser( 'Foo' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \BadMethodCallException $ex ) { + $this->assertSame( + 'MediaWiki\\Session\\SessionProvider::preventSessionsForUser must be implemented ' . + 'when canChangeUser() is false', + $ex->getMessage() + ); + } + } + + public function testHashToSessionId() { + $config = new \HashConfig( [ + 'SecretKey' => 'Shhh!', + ] ); + + $provider = $this->getMockForAbstractClass( SessionProvider::class, + [], 'MockSessionProvider' ); + $provider->setConfig( $config ); + $priv = TestingAccessWrapper::newFromObject( $provider ); + + $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) ); + $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9', + $priv->hashToSessionId( 'foobar', 'secret' ) ); + + try { + $priv->hashToSessionId( [] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$data must be a string, array was passed', + $ex->getMessage() + ); + } + try { + $priv->hashToSessionId( '', false ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + '$key must be a string or null, boolean was passed', + $ex->getMessage() + ); + } + } + + public function testDescribe() { + $provider = $this->getMockForAbstractClass( SessionProvider::class, + [], 'MockSessionProvider' ); + + $this->assertSame( + 'MockSessionProvider sessions', + $provider->describe( \Language::factory( 'en' ) ) + ); + } + + public function testGetAllowedUserRights() { + $provider = $this->getMockForAbstractClass( SessionProvider::class ); + $backend = TestUtils::getDummySessionBackend(); + + try { + $provider->getAllowedUserRights( $backend ); + $this->fail( 'Expected exception not thrown' ); + } catch ( \InvalidArgumentException $ex ) { + $this->assertSame( + 'Backend\'s provider isn\'t $this', + $ex->getMessage() + ); + } + + TestingAccessWrapper::newFromObject( $backend )->provider = $provider; + $this->assertNull( $provider->getAllowedUserRights( $backend ) ); + } + +} diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php new file mode 100644 index 0000000000..a74056d0ca --- /dev/null +++ b/tests/phpunit/includes/session/SessionTest.php @@ -0,0 +1,373 @@ +requests = [ -1 => 'dummy' ]; + TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' ); + + $session = new Session( $backend, 42, new \TestLogger ); + $priv = TestingAccessWrapper::newFromObject( $session ); + $this->assertSame( $backend, $priv->backend ); + $this->assertSame( 42, $priv->index ); + + $request = new \FauxRequest(); + $priv2 = TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) ); + $this->assertSame( $backend, $priv2->backend ); + $this->assertNotSame( $priv->index, $priv2->index ); + $this->assertSame( $request, $priv2->getRequest() ); + } + + /** + * @dataProvider provideMethods + * @param string $m Method to test + * @param array $args Arguments to pass to the method + * @param bool $index Whether the backend method gets passed the index + * @param bool $ret Whether the method returns a value + */ + public function testMethods( $m, $args, $index, $ret ) { + $mock = $this->getMockBuilder( DummySessionBackend::class ) + ->setMethods( [ $m, 'deregisterSession' ] ) + ->getMock(); + $mock->expects( $this->once() )->method( 'deregisterSession' ) + ->with( $this->identicalTo( 42 ) ); + + $tmp = $mock->expects( $this->once() )->method( $m ); + $expectArgs = []; + if ( $index ) { + $expectArgs[] = $this->identicalTo( 42 ); + } + foreach ( $args as $arg ) { + $expectArgs[] = $this->identicalTo( $arg ); + } + $tmp = call_user_func_array( [ $tmp, 'with' ], $expectArgs ); + + $retval = new \stdClass; + $tmp->will( $this->returnValue( $retval ) ); + + $session = TestUtils::getDummySession( $mock, 42 ); + + if ( $ret ) { + $this->assertSame( $retval, call_user_func_array( [ $session, $m ], $args ) ); + } else { + $this->assertNull( call_user_func_array( [ $session, $m ], $args ) ); + } + + // Trigger Session destructor + $session = null; + } + + public static function provideMethods() { + return [ + [ 'getId', [], false, true ], + [ 'getSessionId', [], false, true ], + [ 'resetId', [], false, true ], + [ 'getProvider', [], false, true ], + [ 'isPersistent', [], false, true ], + [ 'persist', [], false, false ], + [ 'unpersist', [], false, false ], + [ 'shouldRememberUser', [], false, true ], + [ 'setRememberUser', [ true ], false, false ], + [ 'getRequest', [], true, true ], + [ 'getUser', [], false, true ], + [ 'getAllowedUserRights', [], false, true ], + [ 'canSetUser', [], false, true ], + [ 'setUser', [ new \stdClass ], false, false ], + [ 'suggestLoginUsername', [], true, true ], + [ 'shouldForceHTTPS', [], false, true ], + [ 'setForceHTTPS', [ true ], false, false ], + [ 'getLoggedOutTimestamp', [], false, true ], + [ 'setLoggedOutTimestamp', [ 123 ], false, false ], + [ 'getProviderMetadata', [], false, true ], + [ 'save', [], false, false ], + [ 'delaySave', [], false, true ], + [ 'renew', [], false, false ], + ]; + } + + public function testDataAccess() { + $session = TestUtils::getDummySession(); + $backend = TestingAccessWrapper::newFromObject( $session )->backend; + + $this->assertEquals( 1, $session->get( 'foo' ) ); + $this->assertEquals( 'zero', $session->get( 0 ) ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( null, $session->get( 'null' ) ); + $this->assertEquals( 'default', $session->get( 'null', 'default' ) ); + $this->assertFalse( $backend->dirty ); + + $session->set( 'foo', 55 ); + $this->assertEquals( 55, $backend->data['foo'] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertEquals( 'one', $backend->data[1] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->set( 1, 'one' ); + $this->assertFalse( $backend->dirty ); + + $this->assertTrue( $session->exists( 'foo' ) ); + $this->assertTrue( $session->exists( 1 ) ); + $this->assertFalse( $session->exists( 'null' ) ); + $this->assertFalse( $session->exists( 100 ) ); + $this->assertFalse( $backend->dirty ); + + $session->remove( 'foo' ); + $this->assertArrayNotHasKey( 'foo', $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + $session->remove( 1 ); + $this->assertArrayNotHasKey( 1, $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session->remove( 101 ); + $this->assertFalse( $backend->dirty ); + + $backend->data = [ 'a', 'b', '?' => 'c' ]; + $this->assertSame( 3, $session->count() ); + $this->assertSame( 3, count( $session ) ); + $this->assertFalse( $backend->dirty ); + + $data = []; + foreach ( $session as $key => $value ) { + $data[$key] = $value; + } + $this->assertEquals( $backend->data, $data ); + $this->assertFalse( $backend->dirty ); + + $this->assertEquals( $backend->data, iterator_to_array( $session ) ); + $this->assertFalse( $backend->dirty ); + } + + public function testArrayAccess() { + $logger = new \TestLogger; + $session = TestUtils::getDummySession( null, -1, $logger ); + $backend = TestingAccessWrapper::newFromObject( $session )->backend; + + $this->assertEquals( 1, $session['foo'] ); + $this->assertEquals( 'zero', $session[0] ); + $this->assertFalse( $backend->dirty ); + + $logger->setCollect( true ); + $this->assertEquals( null, $session['null'] ); + $logger->setCollect( false ); + $this->assertFalse( $backend->dirty ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): null' ] + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $session['foo'] = 55; + $this->assertEquals( 55, $backend->data['foo'] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session[1] = 'one'; + $this->assertEquals( 'one', $backend->data[1] ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + $session[1] = 'one'; + $this->assertFalse( $backend->dirty ); + + $session['bar'] = [ 'baz' => [] ]; + $session['bar']['baz']['quux'] = 2; + $this->assertEquals( [ 'baz' => [ 'quux' => 2 ] ], $backend->data['bar'] ); + + $logger->setCollect( true ); + $session['bar2']['baz']['quux'] = 3; + $logger->setCollect( false ); + $this->assertEquals( [ 'baz' => [ 'quux' => 3 ] ], $backend->data['bar2'] ); + $this->assertSame( [ + [ LogLevel::DEBUG, 'Undefined index (auto-adds to session with a null value): bar2' ] + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + $backend->dirty = false; + $this->assertTrue( isset( $session['foo'] ) ); + $this->assertTrue( isset( $session[1] ) ); + $this->assertFalse( isset( $session['null'] ) ); + $this->assertFalse( isset( $session['missing'] ) ); + $this->assertFalse( isset( $session[100] ) ); + $this->assertFalse( $backend->dirty ); + + unset( $session['foo'] ); + $this->assertArrayNotHasKey( 'foo', $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + unset( $session[1] ); + $this->assertArrayNotHasKey( 1, $backend->data ); + $this->assertTrue( $backend->dirty ); + $backend->dirty = false; + + unset( $session[101] ); + $this->assertFalse( $backend->dirty ); + } + + public function testClear() { + $session = TestUtils::getDummySession(); + $priv = TestingAccessWrapper::newFromObject( $session ); + + $backend = $this->getMockBuilder( DummySessionBackend::class ) + ->setMethods( [ 'canSetUser', 'setUser', 'save' ] ) + ->getMock(); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( [], $backend->data ); + $this->assertTrue( $backend->dirty ); + + $backend = $this->getMockBuilder( DummySessionBackend::class ) + ->setMethods( [ 'canSetUser', 'setUser', 'save' ] ) + ->getMock(); + $backend->data = []; + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( true ) ); + $backend->expects( $this->once() )->method( 'setUser' ) + ->with( $this->callback( function ( $user ) { + return $user instanceof User && $user->isAnon(); + } ) ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertFalse( $backend->dirty ); + + $backend = $this->getMockBuilder( DummySessionBackend::class ) + ->setMethods( [ 'canSetUser', 'setUser', 'save' ] ) + ->getMock(); + $backend->expects( $this->once() )->method( 'canSetUser' ) + ->will( $this->returnValue( false ) ); + $backend->expects( $this->never() )->method( 'setUser' ); + $backend->expects( $this->once() )->method( 'save' ); + $priv->backend = $backend; + $session->clear(); + $this->assertSame( [], $backend->data ); + $this->assertTrue( $backend->dirty ); + } + + public function testTokens() { + $session = TestUtils::getDummySession(); + $priv = TestingAccessWrapper::newFromObject( $session ); + $backend = $priv->backend; + + $token = TestingAccessWrapper::newFromObject( $session->getToken() ); + $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data ); + $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] ); + $secret = $backend->data['wsTokenSecrets']['default']; + $this->assertSame( $secret, $token->secret ); + $this->assertSame( '', $token->salt ); + $this->assertTrue( $token->wasNew() ); + + $token = TestingAccessWrapper::newFromObject( $session->getToken( 'foo' ) ); + $this->assertSame( $secret, $token->secret ); + $this->assertSame( 'foo', $token->salt ); + $this->assertFalse( $token->wasNew() ); + + $backend->data['wsTokenSecrets']['secret'] = 'sekret'; + $token = TestingAccessWrapper::newFromObject( + $session->getToken( [ 'bar', 'baz' ], 'secret' ) + ); + $this->assertSame( 'sekret', $token->secret ); + $this->assertSame( 'bar|baz', $token->salt ); + $this->assertFalse( $token->wasNew() ); + + $session->resetToken( 'secret' ); + $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data ); + $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] ); + $this->assertArrayNotHasKey( 'secret', $backend->data['wsTokenSecrets'] ); + + $session->resetAllTokens(); + $this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data ); + } + + /** + * @dataProvider provideSecretsRoundTripping + * @param mixed $data + */ + public function testSecretsRoundTripping( $data ) { + $session = TestUtils::getDummySession(); + + // Simple round-trip + $session->setSecret( 'secret', $data ); + $this->assertNotEquals( $data, $session->get( 'secret' ) ); + $this->assertEquals( $data, $session->getSecret( 'secret', 'defaulted' ) ); + } + + public static function provideSecretsRoundTripping() { + return [ + [ 'Foobar' ], + [ 42 ], + [ [ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], + [ (object)[ 'foo', 'bar' => 'baz', 'subarray' => [ 1, 2, 3 ] ] ], + [ true ], + [ false ], + [ null ], + ]; + } + + public function testSecrets() { + $logger = new \TestLogger; + $session = TestUtils::getDummySession( null, -1, $logger ); + + // Simple defaulting + $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) ); + + // Bad encrypted data + $session->set( 'test', 'foobar' ); + $logger->setCollect( true ); + $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) ); + $logger->setCollect( false ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Invalid sealed-secret format' ] + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Tampered data + $session->setSecret( 'test', 'foobar' ); + $encrypted = $session->get( 'test' ); + $session->set( 'test', $encrypted . 'x' ); + $logger->setCollect( true ); + $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) ); + $logger->setCollect( false ); + $this->assertSame( [ + [ LogLevel::WARNING, 'Sealed secret has been tampered with, aborting.' ] + ], $logger->getBuffer() ); + $logger->clearBuffer(); + + // Unserializable data + $iv = random_bytes( 16 ); + list( $encKey, $hmacKey ) = TestingAccessWrapper::newFromObject( $session )->getSecretKeys(); + $ciphertext = openssl_encrypt( 'foobar', 'aes-256-ctr', $encKey, OPENSSL_RAW_DATA, $iv ); + $sealed = base64_encode( $iv ) . '.' . base64_encode( $ciphertext ); + $hmac = hash_hmac( 'sha256', $sealed, $hmacKey, true ); + $encrypted = base64_encode( $hmac ) . '.' . $sealed; + $session->set( 'test', $encrypted ); + \Wikimedia\suppressWarnings(); + $this->assertEquals( 'defaulted', $session->getSecret( 'test', 'defaulted' ) ); + \Wikimedia\restoreWarnings(); + } + +} diff --git a/tests/phpunit/includes/session/TokenTest.php b/tests/phpunit/includes/session/TokenTest.php new file mode 100644 index 0000000000..47976527ca --- /dev/null +++ b/tests/phpunit/includes/session/TokenTest.php @@ -0,0 +1,67 @@ +getMockBuilder( Token::class ) + ->setMethods( [ 'toStringAtTimestamp' ] ) + ->setConstructorArgs( [ 'sekret', 'salty', true ] ) + ->getMock(); + $token->expects( $this->any() )->method( 'toStringAtTimestamp' ) + ->will( $this->returnValue( 'faketoken+\\' ) ); + + $this->assertSame( 'faketoken+\\', $token->toString() ); + $this->assertSame( 'faketoken+\\', (string)$token ); + $this->assertTrue( $token->wasNew() ); + + $token = new Token( 'sekret', 'salty', false ); + $this->assertFalse( $token->wasNew() ); + } + + public function testToStringAtTimestamp() { + $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) ); + + $this->assertSame( + 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\', + $token->toStringAtTimestamp( 1447362018 ) + ); + $this->assertSame( + 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\', + $token->toStringAtTimestamp( 1447362026 ) + ); + } + + public function testGetTimestamp() { + $this->assertSame( + 1447362018, Token::getTimestamp( 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\' ) + ); + $this->assertSame( + 1447362026, Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\' ) + ); + $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) ); + $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be+\\' ) ); + + $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9x76c224cfb400d43be5644fdea+\\' ) ); + } + + public function testMatch() { + $token = TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) ); + + $test = $token->toStringAtTimestamp( time() - 10 ); + $this->assertTrue( $token->match( $test ) ); + $this->assertTrue( $token->match( $test, 12 ) ); + $this->assertFalse( $token->match( $test, 8 ) ); + + $this->assertFalse( $token->match( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) ); + } + +} diff --git a/tests/phpunit/includes/shell/CommandFactoryTest.php b/tests/phpunit/includes/shell/CommandFactoryTest.php new file mode 100644 index 0000000000..b031431af7 --- /dev/null +++ b/tests/phpunit/includes/shell/CommandFactoryTest.php @@ -0,0 +1,50 @@ + 1000, + 'memory' => 1000, + 'time' => 30, + 'walltime' => 40, + ]; + + $factory = new CommandFactory( $limits, $cgroup, false ); + $factory->setLogger( $logger ); + $factory->logStderr(); + $command = $factory->create(); + $this->assertInstanceOf( Command::class, $command ); + + $wrapper = TestingAccessWrapper::newFromObject( $command ); + $this->assertSame( $logger, $wrapper->logger ); + $this->assertSame( $cgroup, $wrapper->cgroup ); + $this->assertSame( $limits, $wrapper->limits ); + $this->assertTrue( $wrapper->doLogStderr ); + } + + /** + * @covers MediaWiki\Shell\CommandFactory::create + */ + public function testFirejailCreate() { + $factory = new CommandFactory( [], false, 'firejail' ); + $factory->setLogger( new NullLogger() ); + $this->assertInstanceOf( FirejailCommand::class, $factory->create() ); + } +} diff --git a/tests/phpunit/includes/shell/CommandTest.php b/tests/phpunit/includes/shell/CommandTest.php new file mode 100644 index 0000000000..2e03163885 --- /dev/null +++ b/tests/phpunit/includes/shell/CommandTest.php @@ -0,0 +1,181 @@ +markTestSkipped( 'This test requires a POSIX environment.' ); + } + } + + /** + * @dataProvider provideExecute + */ + public function testExecute( $commandInput, $expectedExitCode, $expectedOutput ) { + $this->requirePosix(); + + $command = new Command(); + $result = $command + ->params( $commandInput ) + ->execute(); + + $this->assertSame( $expectedExitCode, $result->getExitCode() ); + $this->assertSame( $expectedOutput, $result->getStdout() ); + } + + public function provideExecute() { + return [ + 'success status' => [ 'true', 0, '' ], + 'failure status' => [ 'false', 1, '' ], + 'output' => [ [ 'echo', '-n', 'x', '>', 'y' ], 0, 'x > y' ], + ]; + } + + public function testEnvironment() { + $this->requirePosix(); + + $command = new Command(); + $result = $command + ->params( [ 'printenv', 'FOO' ] ) + ->environment( [ 'FOO' => 'bar' ] ) + ->execute(); + $this->assertSame( "bar\n", $result->getStdout() ); + } + + public function testStdout() { + $this->requirePosix(); + + $command = new Command(); + + $result = $command + ->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' ) + ->execute(); + + $this->assertNotContains( 'ThisIsStderr', $result->getStdout() ); + $this->assertEquals( "ThisIsStderr\n", $result->getStderr() ); + } + + public function testStdoutRedirection() { + $this->requirePosix(); + + $command = new Command(); + + $result = $command + ->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' ) + ->includeStderr( true ) + ->execute(); + + $this->assertEquals( "ThisIsStderr\n", $result->getStdout() ); + $this->assertNull( $result->getStderr() ); + } + + public function testOutput() { + global $IP; + + $this->requirePosix(); + chdir( $IP ); + + $command = new Command(); + $result = $command + ->params( [ 'ls', 'index.php' ] ) + ->execute(); + $this->assertRegExp( '/^index.php$/m', $result->getStdout() ); + $this->assertSame( null, $result->getStderr() ); + + $command = new Command(); + $result = $command + ->params( [ 'ls', 'index.php', 'no-such-file' ] ) + ->includeStderr() + ->execute(); + $this->assertRegExp( '/^index.php$/m', $result->getStdout() ); + $this->assertRegExp( '/^.+no-such-file.*$/m', $result->getStdout() ); + $this->assertSame( null, $result->getStderr() ); + + $command = new Command(); + $result = $command + ->params( [ 'ls', 'index.php', 'no-such-file' ] ) + ->execute(); + $this->assertRegExp( '/^index.php$/m', $result->getStdout() ); + $this->assertRegExp( '/^.+no-such-file.*$/m', $result->getStderr() ); + } + + /** + * Test that null values are skipped by params() and unsafeParams() + */ + public function testNullsAreSkipped() { + $command = TestingAccessWrapper::newFromObject( new Command ); + $command->params( 'echo', 'a', null, 'b' ); + $command->unsafeParams( 'c', null, 'd' ); + $this->assertEquals( "'echo' 'a' 'b' c d", $command->command ); + } + + public function testT69870() { + $commandLine = wfIsWindows() + // 333 = 331 + CRLF + ? ( 'for /l %i in (1, 1, 1001) do @echo ' . str_repeat( '*', 331 ) ) + : 'printf "%-333333s" "*"'; + + // Test several times because it involves a race condition that may randomly succeed or fail + for ( $i = 0; $i < 10; $i++ ) { + $command = new Command(); + $output = $command->unsafeParams( $commandLine ) + ->execute() + ->getStdout(); + $this->assertEquals( 333333, strlen( $output ) ); + } + } + + public function testLogStderr() { + $this->requirePosix(); + + $logger = new TestLogger( true, function ( $message, $level, $context ) { + return $level === Psr\Log\LogLevel::ERROR ? '1' : null; + }, true ); + $command = new Command(); + $command->setLogger( $logger ); + $command->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' ); + $command->execute(); + $this->assertEmpty( $logger->getBuffer() ); + + $command = new Command(); + $command->setLogger( $logger ); + $command->logStderr(); + $command->params( 'bash', '-c', 'echo ThisIsStderr 1>&2' ); + $command->execute(); + $this->assertSame( 1, count( $logger->getBuffer() ) ); + $this->assertSame( trim( $logger->getBuffer()[0][2]['error'] ), 'ThisIsStderr' ); + } + + public function testInput() { + $this->requirePosix(); + + $command = new Command(); + $command->params( 'cat' ); + $command->input( 'abc' ); + $result = $command->execute(); + $this->assertSame( 'abc', $result->getStdout() ); + + // now try it with something that does not fit into a single block + $command = new Command(); + $command->params( 'cat' ); + $command->input( str_repeat( '!', 1000000 ) ); + $result = $command->execute(); + $this->assertSame( 1000000, strlen( $result->getStdout() ) ); + + // And try it with empty input + $command = new Command(); + $command->params( 'cat' ); + $command->input( '' ); + $result = $command->execute(); + $this->assertSame( '', $result->getStdout() ); + } +} diff --git a/tests/phpunit/includes/shell/FirejailCommandTest.php b/tests/phpunit/includes/shell/FirejailCommandTest.php new file mode 100644 index 0000000000..681c3dcda0 --- /dev/null +++ b/tests/phpunit/includes/shell/FirejailCommandTest.php @@ -0,0 +1,85 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + * + */ + +use MediaWiki\Shell\FirejailCommand; +use MediaWiki\Shell\Shell; +use Wikimedia\TestingAccessWrapper; + +class FirejailCommandTest extends PHPUnit\Framework\TestCase { + + use MediaWikiCoversValidator; + + public function provideBuildFinalCommand() { + global $IP; + // phpcs:ignore Generic.Files.LineLength + $env = "'MW_INCLUDE_STDERR=;MW_CPU_LIMIT=180; MW_CGROUP='\'''\''; MW_MEM_LIMIT=307200; MW_FILE_SIZE_LIMIT=102400; MW_WALL_CLOCK_LIMIT=180; MW_USE_LOG_PIPE=yes'"; + $limit = "/bin/bash '$IP/includes/shell/limit.sh'"; + $profile = "--profile=$IP/includes/shell/firejail.profile"; + $blacklist = '--blacklist=' . realpath( MW_CONFIG_FILE ); + $default = "$blacklist --noroot --seccomp --private-dev"; + return [ + [ + 'No restrictions', + 'ls', 0, "$limit ''\''ls'\''' $env" + ], + [ + 'default restriction', + 'ls', Shell::RESTRICT_DEFAULT, + "$limit 'firejail --quiet $profile $default -- '\''ls'\''' $env" + ], + [ + 'no network', + 'ls', Shell::NO_NETWORK, + "$limit 'firejail --quiet $profile --net=none -- '\''ls'\''' $env" + ], + [ + 'default restriction & no network', + 'ls', Shell::RESTRICT_DEFAULT | Shell::NO_NETWORK, + "$limit 'firejail --quiet $profile $default --net=none -- '\''ls'\''' $env" + ], + [ + 'seccomp', + 'ls', Shell::SECCOMP, + "$limit 'firejail --quiet $profile --seccomp -- '\''ls'\''' $env" + ], + [ + 'seccomp & no execve', + 'ls', Shell::SECCOMP | Shell::NO_EXECVE, + "$limit 'firejail --quiet $profile --shell=none --seccomp=execve -- '\''ls'\''' $env" + ], + ]; + } + + /** + * @covers \MediaWiki\Shell\FirejailCommand::buildFinalCommand() + * @dataProvider provideBuildFinalCommand + */ + public function testBuildFinalCommand( $desc, $params, $flags, $expected ) { + $command = new FirejailCommand( 'firejail' ); + $command + ->params( $params ) + ->restrict( $flags ); + $wrapper = TestingAccessWrapper::newFromObject( $command ); + $output = $wrapper->buildFinalCommand( $wrapper->command ); + $this->assertEquals( $expected, $output[0], $desc ); + } + +} diff --git a/tests/phpunit/includes/site/CachingSiteStoreTest.php b/tests/phpunit/includes/site/CachingSiteStoreTest.php new file mode 100644 index 0000000000..f04d35ca02 --- /dev/null +++ b/tests/phpunit/includes/site/CachingSiteStoreTest.php @@ -0,0 +1,167 @@ + + */ +class CachingSiteStoreTest extends MediaWikiTestCase { + + /** + * @covers CachingSiteStore::getSites + */ + public function testGetSites() { + $testSites = TestSites::getSites(); + + $store = new CachingSiteStore( + $this->getHashSiteStore( $testSites ), + ObjectCache::getLocalClusterInstance() + ); + + $sites = $store->getSites(); + + $this->assertInstanceOf( SiteList::class, $sites ); + + /** + * @var Site $site + */ + foreach ( $sites as $site ) { + $this->assertInstanceOf( Site::class, $site ); + } + + foreach ( $testSites as $site ) { + if ( $site->getGlobalId() !== null ) { + $this->assertTrue( $sites->hasSite( $site->getGlobalId() ) ); + } + } + } + + /** + * @covers CachingSiteStore::saveSites + */ + public function testSaveSites() { + $store = new CachingSiteStore( + new HashSiteStore(), ObjectCache::getLocalClusterInstance() + ); + + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'ertrywuutr' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'sdfhxujgkfpth' ); + $site->setLanguageCode( 'nl' ); + $sites[] = $site; + + $this->assertTrue( $store->saveSites( $sites ) ); + + $site = $store->getSite( 'ertrywuutr' ); + $this->assertInstanceOf( Site::class, $site ); + $this->assertEquals( 'en', $site->getLanguageCode() ); + + $site = $store->getSite( 'sdfhxujgkfpth' ); + $this->assertInstanceOf( Site::class, $site ); + $this->assertEquals( 'nl', $site->getLanguageCode() ); + } + + /** + * @covers CachingSiteStore::reset + */ + public function testReset() { + $dbSiteStore = $this->getMockBuilder( SiteStore::class ) + ->disableOriginalConstructor() + ->getMock(); + + $dbSiteStore->expects( $this->any() ) + ->method( 'getSite' ) + ->will( $this->returnValue( $this->getTestSite() ) ); + + $dbSiteStore->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnCallback( function () { + $siteList = new SiteList(); + $siteList->setSite( $this->getTestSite() ); + + return $siteList; + } ) ); + + $store = new CachingSiteStore( $dbSiteStore, ObjectCache::getLocalClusterInstance() ); + + // initialize internal cache + $this->assertGreaterThan( 0, $store->getSites()->count(), 'count sites' ); + + $store->getSite( 'enwiki' )->setLanguageCode( 'en-ca' ); + + // sanity check: $store should have the new language code for 'enwiki' + $this->assertEquals( 'en-ca', $store->getSite( 'enwiki' )->getLanguageCode(), 'sanity check' ); + + // purge cache + $store->reset(); + + // the internal cache of $store should be updated, and now pulling + // the site from the 'fallback' DBSiteStore with the original language code. + $this->assertEquals( 'en', $store->getSite( 'enwiki' )->getLanguageCode(), 'reset' ); + } + + public function getTestSite() { + $enwiki = new MediaWikiSite(); + $enwiki->setGlobalId( 'enwiki' ); + $enwiki->setLanguageCode( 'en' ); + + return $enwiki; + } + + /** + * @covers CachingSiteStore::clear + */ + public function testClear() { + $store = new CachingSiteStore( + new HashSiteStore(), ObjectCache::getLocalClusterInstance() + ); + $this->assertTrue( $store->clear() ); + + $site = $store->getSite( 'enwiki' ); + $this->assertNull( $site ); + + $sites = $store->getSites(); + $this->assertEquals( 0, $sites->count() ); + } + + /** + * @param Site[] $sites + * + * @return SiteStore + */ + private function getHashSiteStore( array $sites ) { + $siteStore = new HashSiteStore(); + $siteStore->saveSites( $sites ); + + return $siteStore; + } + +} diff --git a/tests/phpunit/includes/site/HashSiteStoreTest.php b/tests/phpunit/includes/site/HashSiteStoreTest.php new file mode 100644 index 0000000000..6269fd39dc --- /dev/null +++ b/tests/phpunit/includes/site/HashSiteStoreTest.php @@ -0,0 +1,105 @@ + + */ +class HashSiteStoreTest extends MediaWikiTestCase { + + /** + * @covers HashSiteStore::getSites + */ + public function testGetSites() { + $expectedSites = []; + + foreach ( TestSites::getSites() as $testSite ) { + $siteId = $testSite->getGlobalId(); + $expectedSites[$siteId] = $testSite; + } + + $siteStore = new HashSiteStore( $expectedSites ); + + $this->assertEquals( new SiteList( $expectedSites ), $siteStore->getSites() ); + } + + /** + * @covers HashSiteStore::saveSite + * @covers HashSiteStore::getSite + */ + public function testSaveSite() { + $store = new HashSiteStore(); + + $site = new Site(); + $site->setGlobalId( 'dewiki' ); + + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + + $store->saveSite( $site ); + + $this->assertCount( 1, $store->getSites(), 'Store has 1 sites' ); + $this->assertEquals( $site, $store->getSite( 'dewiki' ), 'Store has dewiki' ); + } + + /** + * @covers HashSiteStore::saveSites + */ + public function testSaveSites() { + $store = new HashSiteStore(); + + $sites = []; + + $site = new Site(); + $site->setGlobalId( 'enwiki' ); + $site->setLanguageCode( 'en' ); + $sites[] = $site; + + $site = new MediaWikiSite(); + $site->setGlobalId( 'eswiki' ); + $site->setLanguageCode( 'es' ); + $sites[] = $site; + + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + + $store->saveSites( $sites ); + + $this->assertCount( 2, $store->getSites(), 'Store has 2 sites' ); + $this->assertTrue( $store->getSites()->hasSite( 'enwiki' ), 'Store has enwiki' ); + $this->assertTrue( $store->getSites()->hasSite( 'eswiki' ), 'Store has eswiki' ); + } + + /** + * @covers HashSiteStore::clear + */ + public function testClear() { + $store = new HashSiteStore(); + + $site = new Site(); + $site->setGlobalId( 'arwiki' ); + $store->saveSite( $site ); + + $this->assertCount( 1, $store->getSites(), '1 site in store' ); + + $store->clear(); + $this->assertCount( 0, $store->getSites(), '0 sites in store' ); + } +} diff --git a/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php new file mode 100644 index 0000000000..15894a3d9a --- /dev/null +++ b/tests/phpunit/includes/site/MediaWikiPageNameNormalizerTest.php @@ -0,0 +1,116 @@ +assertSame( + $expected, + $normalizer->normalizePageName( $pageName, 'https://www.wikidata.org/w/api.php' ) + ); + } + + public function normalizePageTitleProvider() { + // Response are taken from wikidata and kkwiki using the following API request + // api.php?action=query&prop=info&redirects=1&converttitles=1&format=json&titles=… + return [ + 'universe (Q1)' => [ + 'Q1', + 'Q1', + '{"batchcomplete":"","query":{"pages":{"129":{"pageid":129,"ns":0,' + . '"title":"Q1","contentmodel":"wikibase-item","pagelanguage":"en",' + . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' + . '"touched":"2016-06-23T05:11:21Z","lastrevid":350004448,"length":58001}}}}' + ], + 'Q404 redirects to Q395' => [ + 'Q395', + 'Q404', + '{"batchcomplete":"","query":{"redirects":[{"from":"Q404","to":"Q395"}],"pages"' + . ':{"601":{"pageid":601,"ns":0,"title":"Q395","contentmodel":"wikibase-item",' + . '"pagelanguage":"en","pagelanguagehtmlcode":"en","pagelanguagedir":"ltr",' + . '"touched":"2016-06-23T08:00:20Z","lastrevid":350021914,"length":60108}}}}' + ], + 'D converted to Д (Latin to Cyrillic) (taken from kkwiki)' => [ + 'Д', + 'D', + '{"batchcomplete":"","query":{"converted":[{"from":"D","to":"\u0414"}],' + . '"pages":{"510541":{"pageid":510541,"ns":0,"title":"\u0414",' + . '"contentmodel":"wikitext","pagelanguage":"kk","pagelanguagehtmlcode":"kk",' + . '"pagelanguagedir":"ltr","touched":"2015-11-22T09:16:18Z",' + . '"lastrevid":2373618,"length":3501}}}}' + ], + 'there is no Q0' => [ + false, + 'Q0', + '{"batchcomplete":"","query":{"pages":{"-1":{"ns":0,"title":"Q0",' + . '"missing":"","contentmodel":"wikibase-item","pagelanguage":"en",' + . '"pagelanguagehtmlcode":"en","pagelanguagedir":"ltr"}}}}' + ], + 'invalid title' => [ + false, + '{{', + '{"batchcomplete":"","query":{"pages":{"-1":{"title":"{{",' + . '"invalidreason":"The requested page title contains invalid ' + . 'characters: \"{\".","invalid":""}}}}' + ], + 'error on get' => [ false, 'ABC', false ] + ]; + } + +} + +/** + * @private + * @see Http + */ +class MediaWikiPageNameNormalizerTestMockHttp extends Http { + + /** + * @var mixed + */ + public static $response; + + public static function get( $url, array $options = [], $caller = __METHOD__ ) { + PHPUnit_Framework_Assert::assertInternalType( 'string', $url ); + PHPUnit_Framework_Assert::assertInternalType( 'string', $caller ); + + return self::$response; + } +} diff --git a/tests/phpunit/includes/site/SiteExporterTest.php b/tests/phpunit/includes/site/SiteExporterTest.php new file mode 100644 index 0000000000..97a43f8d5b --- /dev/null +++ b/tests/phpunit/includes/site/SiteExporterTest.php @@ -0,0 +1,148 @@ +setExpectedException( InvalidArgumentException::class ); + + new SiteExporter( 'Foo' ); + } + + public function testExportSites() { + $foo = Site::newForType( Site::TYPE_UNKNOWN ); + $foo->setGlobalId( 'Foo' ); + + $acme = Site::newForType( Site::TYPE_UNKNOWN ); + $acme->setGlobalId( 'acme.com' ); + $acme->setGroup( 'Test' ); + $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); + $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); + + $tmp = tmpfile(); + $exporter = new SiteExporter( $tmp ); + + $exporter->exportSites( [ $foo, $acme ] ); + + fseek( $tmp, 0 ); + $xml = fread( $tmp, 16 * 1024 ); + + $this->assertContains( 'assertContains( '', $xml ); + $this->assertContains( 'Foo', $xml ); + $this->assertContains( '', $xml ); + $this->assertContains( 'acme.com', $xml ); + $this->assertContains( 'Test', $xml ); + $this->assertContains( 'acme', $xml ); + $this->assertContains( 'http://acme.com/', $xml ); + $this->assertContains( '', $xml ); + + // NOTE: HHVM (at least on wmf Jenkins) doesn't like file URLs. + $xsdFile = __DIR__ . '/../../../../docs/sitelist-1.0.xsd'; + $xsdData = file_get_contents( $xsdFile ); + + $document = new DOMDocument(); + $document->loadXML( $xml, LIBXML_NONET ); + $document->schemaValidateSource( $xsdData ); + } + + private function newSiteStore( SiteList $sites ) { + $store = $this->getMockBuilder( SiteStore::class )->getMock(); + + $store->expects( $this->once() ) + ->method( 'saveSites' ) + ->will( $this->returnCallback( function ( $moreSites ) use ( $sites ) { + foreach ( $moreSites as $site ) { + $sites->setSite( $site ); + } + } ) ); + + $store->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnValue( new SiteList() ) ); + + return $store; + } + + public function provideRoundTrip() { + $foo = Site::newForType( Site::TYPE_UNKNOWN ); + $foo->setGlobalId( 'Foo' ); + + $acme = Site::newForType( Site::TYPE_UNKNOWN ); + $acme->setGlobalId( 'acme.com' ); + $acme->setGroup( 'Test' ); + $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); + $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); + + $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); + $dewiki->setGlobalId( 'dewiki' ); + $dewiki->setGroup( 'wikipedia' ); + $dewiki->setForward( true ); + $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); + $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); + $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); + $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); + $dewiki->setSource( 'meta.wikimedia.org' ); + + return [ + 'empty' => [ + new SiteList() + ], + + 'some' => [ + new SiteList( [ $foo, $acme, $dewiki ] ), + ], + ]; + } + + /** + * @dataProvider provideRoundTrip() + */ + public function testRoundTrip( SiteList $sites ) { + $tmp = tmpfile(); + $exporter = new SiteExporter( $tmp ); + + $exporter->exportSites( $sites ); + + fseek( $tmp, 0 ); + $xml = fread( $tmp, 16 * 1024 ); + + $actualSites = new SiteList(); + $store = $this->newSiteStore( $actualSites ); + + $importer = new SiteImporter( $store ); + $importer->importFromXML( $xml ); + + $this->assertEquals( $sites, $actualSites ); + } + +} diff --git a/tests/phpunit/includes/site/SiteImporterTest.php b/tests/phpunit/includes/site/SiteImporterTest.php new file mode 100644 index 0000000000..dbdbd6fcc2 --- /dev/null +++ b/tests/phpunit/includes/site/SiteImporterTest.php @@ -0,0 +1,200 @@ +getMockBuilder( SiteStore::class )->getMock(); + + $store->expects( $this->once() ) + ->method( 'saveSites' ) + ->will( $this->returnCallback( function ( $sites ) use ( $expectedSites ) { + $this->assertSitesEqual( $expectedSites, $sites ); + } ) ); + + $store->expects( $this->any() ) + ->method( 'getSites' ) + ->will( $this->returnValue( new SiteList() ) ); + + $errorHandler = $this->getMockBuilder( Psr\Log\LoggerInterface::class )->getMock(); + $errorHandler->expects( $this->exactly( $errorCount ) ) + ->method( 'error' ); + + $importer = new SiteImporter( $store ); + $importer->setExceptionCallback( [ $errorHandler, 'error' ] ); + + return $importer; + } + + public function assertSitesEqual( $expected, $actual, $message = '' ) { + $this->assertEquals( + $this->getSerializedSiteList( $expected ), + $this->getSerializedSiteList( $actual ), + $message + ); + } + + public function provideImportFromXML() { + $foo = Site::newForType( Site::TYPE_UNKNOWN ); + $foo->setGlobalId( 'Foo' ); + + $acme = Site::newForType( Site::TYPE_UNKNOWN ); + $acme->setGlobalId( 'acme.com' ); + $acme->setGroup( 'Test' ); + $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); + $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); + + $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); + $dewiki->setGlobalId( 'dewiki' ); + $dewiki->setGroup( 'wikipedia' ); + $dewiki->setForward( true ); + $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); + $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); + $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); + $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); + $dewiki->setSource( 'meta.wikimedia.org' ); + + return [ + 'empty' => [ + '', + [], + ], + 'no sites' => [ + 'FooBla', + [], + ], + 'minimal' => [ + '' . + 'Foo' . + '', + [ $foo ], + ], + 'full' => [ + '' . + 'Foo' . + '' . + 'acme.com' . + 'acme' . + 'Test' . + 'http://acme.com/' . + '' . + '' . + 'meta.wikimedia.org' . + 'dewiki' . + 'wikipedia' . + 'de' . + 'wikipedia' . + '' . + 'http://de.wikipedia.org/w/' . + 'http://de.wikipedia.org/wiki/' . + '' . + '', + [ $foo, $acme, $dewiki ], + ], + 'skip' => [ + '' . + 'Foo' . + 'Foo' . + '' . + 'acme.com' . + 'acme' . + 'boop!' . + 'Test' . + 'http://acme.com/' . + '' . + '', + [ $foo, $acme ], + 1 + ], + ]; + } + + /** + * @dataProvider provideImportFromXML + */ + public function testImportFromXML( $xml, array $expectedSites, $errorCount = 0 ) { + $importer = $this->newSiteImporter( $expectedSites, $errorCount ); + $importer->importFromXML( $xml ); + } + + public function testImportFromXML_malformed() { + $this->setExpectedException( Exception::class ); + + $store = $this->getMockBuilder( SiteStore::class )->getMock(); + $importer = new SiteImporter( $store ); + $importer->importFromXML( 'THIS IS NOT XML' ); + } + + public function testImportFromFile() { + $foo = Site::newForType( Site::TYPE_UNKNOWN ); + $foo->setGlobalId( 'Foo' ); + + $acme = Site::newForType( Site::TYPE_UNKNOWN ); + $acme->setGlobalId( 'acme.com' ); + $acme->setGroup( 'Test' ); + $acme->addLocalId( Site::ID_INTERWIKI, 'acme' ); + $acme->setPath( Site::PATH_LINK, 'http://acme.com/' ); + + $dewiki = Site::newForType( Site::TYPE_MEDIAWIKI ); + $dewiki->setGlobalId( 'dewiki' ); + $dewiki->setGroup( 'wikipedia' ); + $dewiki->setForward( true ); + $dewiki->addLocalId( Site::ID_INTERWIKI, 'wikipedia' ); + $dewiki->addLocalId( Site::ID_EQUIVALENT, 'de' ); + $dewiki->setPath( Site::PATH_LINK, 'http://de.wikipedia.org/w/' ); + $dewiki->setPath( MediaWikiSite::PATH_PAGE, 'http://de.wikipedia.org/wiki/' ); + $dewiki->setSource( 'meta.wikimedia.org' ); + + $importer = $this->newSiteImporter( [ $foo, $acme, $dewiki ], 0 ); + + $file = __DIR__ . '/SiteImporterTest.xml'; + $importer->importFromFile( $file ); + } + + /** + * @param Site[] $sites + * + * @return array[] + */ + private function getSerializedSiteList( $sites ) { + $serialized = []; + + foreach ( $sites as $site ) { + $key = $site->getGlobalId(); + $data = unserialize( $site->serialize() ); + + $serialized[$key] = $data; + } + + return $serialized; + } +} diff --git a/tests/phpunit/includes/site/SiteImporterTest.xml b/tests/phpunit/includes/site/SiteImporterTest.xml new file mode 100644 index 0000000000..720b1faf1a --- /dev/null +++ b/tests/phpunit/includes/site/SiteImporterTest.xml @@ -0,0 +1,19 @@ + + Foo + + acme.com + acme + Test + http://acme.com/ + + + meta.wikimedia.org + dewiki + wikipedia + de + wikipedia + + http://de.wikipedia.org/w/ + http://de.wikipedia.org/wiki/ + + diff --git a/tests/phpunit/includes/skins/SkinFactoryTest.php b/tests/phpunit/includes/skins/SkinFactoryTest.php new file mode 100644 index 0000000000..4289fd9188 --- /dev/null +++ b/tests/phpunit/includes/skins/SkinFactoryTest.php @@ -0,0 +1,82 @@ +register( 'fallback', 'Fallback', function () { + return new SkinFallback(); + } ); + $this->assertTrue( true ); // No exception thrown + $this->setExpectedException( InvalidArgumentException::class ); + $factory->register( 'invalid', 'Invalid', 'Invalid callback' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithNoBuilders() { + $factory = new SkinFactory(); + $this->setExpectedException( SkinException::class ); + $factory->makeSkin( 'nobuilderregistered' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithInvalidCallback() { + $factory = new SkinFactory(); + $factory->register( 'unittest', 'Unittest', function () { + return true; // Not a Skin object + } ); + $this->setExpectedException( UnexpectedValueException::class ); + $factory->makeSkin( 'unittest' ); + } + + /** + * @covers SkinFactory::makeSkin + */ + public function testMakeSkinWithValidCallback() { + $factory = new SkinFactory(); + $factory->register( 'testfallback', 'TestFallback', function () { + return new SkinFallback(); + } ); + + $skin = $factory->makeSkin( 'testfallback' ); + $this->assertInstanceOf( Skin::class, $skin ); + $this->assertInstanceOf( SkinFallback::class, $skin ); + $this->assertEquals( 'fallback', $skin->getSkinName() ); + } + + /** + * @covers Skin::__construct + * @covers Skin::getSkinName + */ + public function testGetSkinName() { + $skin = new SkinFallback(); + $this->assertEquals( 'fallback', $skin->getSkinName(), 'Default' ); + $skin = new SkinFallback( 'testname' ); + $this->assertEquals( 'testname', $skin->getSkinName(), 'Constructor argument' ); + } + + /** + * @covers SkinFactory::getSkinNames + */ + public function testGetSkinNames() { + $factory = new SkinFactory(); + // A fake callback we can use that will never be called + $callback = function () { + // NOP + }; + $factory->register( 'skin1', 'Skin1', $callback ); + $factory->register( 'skin2', 'Skin2', $callback ); + $names = $factory->getSkinNames(); + $this->assertArrayHasKey( 'skin1', $names ); + $this->assertArrayHasKey( 'skin2', $names ); + $this->assertEquals( 'Skin1', $names['skin1'] ); + $this->assertEquals( 'Skin2', $names['skin2'] ); + } +} diff --git a/tests/phpunit/includes/skins/SkinTemplateTest.php b/tests/phpunit/includes/skins/SkinTemplateTest.php new file mode 100644 index 0000000000..6ea5b40b0b --- /dev/null +++ b/tests/phpunit/includes/skins/SkinTemplateTest.php @@ -0,0 +1,109 @@ + + */ +class SkinTemplateTest extends MediaWikiTestCase { + /** + * @dataProvider makeListItemProvider + */ + public function testMakeListItem( $expected, $key, $item, $options, $message ) { + $template = $this->getMockForAbstractClass( BaseTemplate::class ); + + $this->assertEquals( + $expected, + $template->makeListItem( $key, $item, $options ), + $message + ); + } + + public function makeListItemProvider() { + return [ + [ + '

  • text
  • ', + '', + [ + 'class' => 'class', + 'itemtitle' => 'itemtitle', + 'href' => 'url', + 'title' => 'title', + 'text' => 'text' + ], + [], + 'Test makeListItem with normal values' + ] + ]; + } + + /** + * @return PHPUnit_Framework_MockObject_MockObject|OutputPage + */ + private function getMockOutputPage( $isSyndicated, $html ) { + $mock = $this->getMockBuilder( OutputPage::class ) + ->disableOriginalConstructor() + ->getMock(); + $mock->expects( $this->once() ) + ->method( 'isSyndicated' ) + ->will( $this->returnValue( $isSyndicated ) ); + $mock->expects( $this->any() ) + ->method( 'getHTML' ) + ->will( $this->returnValue( $html ) ); + return $mock; + } + + public function provideGetDefaultModules() { + $defaultStyles = [ + 'mediawiki.legacy.shared', + 'mediawiki.legacy.commonPrint', + ]; + $buttonStyle = 'mediawiki.ui.button'; + $feedStyle = 'mediawiki.feedlink'; + return [ + [ + false, + '', + $defaultStyles + ], + [ + true, + '', + array_merge( $defaultStyles, [ $feedStyle ] ) + ], + [ + false, + 'FOO mw-ui-button BAR', + array_merge( $defaultStyles, [ $buttonStyle ] ) + ], + [ + true, + 'FOO mw-ui-button BAR', + array_merge( $defaultStyles, [ $buttonStyle, $feedStyle ] ) + ], + ]; + } + + /** + * @covers Skin::getDefaultModules + * @dataProvider provideGetDefaultModules + */ + public function testgetDefaultModules( $isSyndicated, $html, $expectedModuleStyles ) { + $skin = new SkinTemplate(); + + $context = new DerivativeContext( $skin->getContext() ); + $context->setOutput( $this->getMockOutputPage( $isSyndicated, $html ) ); + $skin->setContext( $context ); + + $modules = $skin->getDefaultModules(); + + $actualStylesModule = call_user_func_array( 'array_merge', $modules['styles'] ); + $this->assertArraySubset( + $expectedModuleStyles, + $actualStylesModule, + 'style modules' + ); + } +} diff --git a/tests/phpunit/includes/skins/SkinTest.php b/tests/phpunit/includes/skins/SkinTest.php new file mode 100644 index 0000000000..41ef2b796a --- /dev/null +++ b/tests/phpunit/includes/skins/SkinTest.php @@ -0,0 +1,16 @@ +getMockBuilder( Skin::class ) + ->setMethods( [ 'outputPage', 'setupSkinUserCss' ] ) + ->getMock(); + + $modules = $skin->getDefaultModules(); + $this->assertTrue( isset( $modules['core'] ), 'core key is set by default' ); + $this->assertTrue( isset( $modules['styles'] ), 'style key is set by default' ); + } +} diff --git a/tests/phpunit/includes/sparql/SparqlClientTest.php b/tests/phpunit/includes/sparql/SparqlClientTest.php new file mode 100644 index 0000000000..62af48920b --- /dev/null +++ b/tests/phpunit/includes/sparql/SparqlClientTest.php @@ -0,0 +1,191 @@ +getMock( HttpRequestFactory::class ); + $requestFactory->method( 'create' )->willReturn( $request ); + return $requestFactory; + } + + private function getRequestMock( $content ) { + $request = $this->getMockBuilder( MWHttpRequest::class )->disableOriginalConstructor()->getMock(); + $request->method( 'execute' )->willReturn( \Status::newGood( 200 ) ); + $request->method( 'getContent' )->willReturn( $content ); + return $request; + } + + public function testQuery() { + $json = <<getRequestMock( $json ); + $client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) ); + + // values only + $result = $client->query( "TEST SPARQL" ); + $this->assertCount( 2, $result ); + $this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x'] ); + $this->assertEquals( 'http://creativecommons.org/ns#license', $result[0]['y'] ); + $this->assertEquals( '0.1.0', $result[1]['z'] ); + $this->assertNull( $result[1]['y'] ); + // raw data format + $result = $client->query( "TEST SPARQL 2", true ); + $this->assertCount( 2, $result ); + $this->assertEquals( 'uri', $result[0]['x']['type'] ); + $this->assertEquals( 'http://wikiba.se/ontology#Dump', $result[0]['x']['value'] ); + $this->assertEquals( 'literal', $result[1]['z']['type'] ); + $this->assertEquals( '0.1.0', $result[1]['z']['value'] ); + $this->assertNull( $result[1]['y'] ); + } + + /** + * @expectedException \Mediawiki\Sparql\SparqlException + */ + public function testBadQuery() { + $request = $this->getMockBuilder( MWHttpRequest::class )->disableOriginalConstructor()->getMock(); + $client = new SparqlClient( 'http://acme.test/', $this->getRequestFactory( $request ) ); + + $request->method( 'execute' )->willReturn( \Status::newFatal( "Bad query" ) ); + $result = $client->query( "TEST SPARQL 3" ); + } + + public function optionsProvider() { + return [ + 'defaults' => [ + 'TEST тест SPARQL 4 ', + null, + null, + [ + 'http://acme.test/', + 'query=TEST+%D1%82%D0%B5%D1%81%D1%82+SPARQL+4+', + 'format=json', + 'maxQueryTimeMillis=30000', + ], + [ + 'method' => 'GET', + 'userAgent' => Http::userAgent() . " SparqlClient", + 'timeout' => 30 + ] + ], + 'big query' => [ + str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ), + null, + null, + [ + 'format=json', + 'maxQueryTimeMillis=30000', + ], + [ + 'method' => 'POST', + 'postData' => 'query=' . str_repeat( 'ZZ', SparqlClient::MAX_GET_SIZE ), + ] + ], + 'timeout 1s' => [ + 'TEST SPARQL 4', + null, + 1, + [ + 'maxQueryTimeMillis=1000', + ], + [ + 'timeout' => 1 + ] + ], + 'more options' => [ + 'TEST SPARQL 5', + [ + 'userAgent' => 'My Test', + 'randomOption' => 'duck', + ], + null, + [], + [ + 'userAgent' => 'My Test', + 'randomOption' => 'duck', + ] + ], + + ]; + } + + /** + * @dataProvider optionsProvider + * @param string $sparql + * @param array|null $options + * @param int|null $timeout + * @param array $expectedUrl + * @param array $expectedOptions + */ + public function testOptions( $sparql, $options, $timeout, $expectedUrl, $expectedOptions ) { + $requestFactory = $this->getMock( HttpRequestFactory::class ); + $client = new SparqlClient( 'http://acme.test/', $requestFactory ); + + $request = $this->getRequestMock( '{}' ); + + $requestFactory->method( 'create' )->willReturnCallback( + function ( $url, $options ) use ( $request, $expectedUrl, $expectedOptions ) { + foreach ( $expectedUrl as $eurl ) { + $this->assertContains( $eurl, $url ); + } + foreach ( $expectedOptions as $ekey => $evalue ) { + $this->assertArrayHasKey( $ekey, $options ); + $this->assertEquals( $options[$ekey], $evalue ); + } + return $request; + } + ); + + if ( !is_null( $options ) ) { + $client->setClientOptions( $options ); + } + if ( !is_null( $timeout ) ) { + $client->setTimeout( $timeout ); + } + + $result = $client->query( $sparql ); + } + +} diff --git a/tests/phpunit/includes/specials/ImageListPagerTest.php b/tests/phpunit/includes/specials/ImageListPagerTest.php new file mode 100644 index 0000000000..10c6d04c9e --- /dev/null +++ b/tests/phpunit/includes/specials/ImageListPagerTest.php @@ -0,0 +1,21 @@ +formatValue( 'invalid_field', 'invalid_value' ); + } +} diff --git a/tests/phpunit/includes/specials/SpecialUploadTest.php b/tests/phpunit/includes/specials/SpecialUploadTest.php new file mode 100644 index 0000000000..95026c18d0 --- /dev/null +++ b/tests/phpunit/includes/specials/SpecialUploadTest.php @@ -0,0 +1,29 @@ +assertEquals( $expected, $result ); + } + + public function provideGetInitialPageText() { + return [ + [ + 'expect' => "== Summary ==\nthis is a test\n", + 'params' => [ + 'this is a test' + ], + ], + [ + 'expect' => "== Summary ==\nthis is a test\n", + 'params' => [ + "== Summary ==\nthis is a test", + ], + ], + ]; + } +} diff --git a/tests/phpunit/includes/specials/UncategorizedCategoriesPageTest.php b/tests/phpunit/includes/specials/UncategorizedCategoriesPageTest.php new file mode 100644 index 0000000000..80bd365f35 --- /dev/null +++ b/tests/phpunit/includes/specials/UncategorizedCategoriesPageTest.php @@ -0,0 +1,63 @@ +getMockBuilder( RequestContext::class )->getMock(); + $mockContext->method( 'msg' )->willReturn( $msg ); + $special = new UncategorizedCategoriesPage(); + $special->setContext( $mockContext ); + $this->assertEquals( [ + 'tables' => [ + 0 => 'page', + 1 => 'categorylinks', + ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title', + ], + 'conds' => [ + 0 => 'cl_from IS NULL', + 'page_namespace' => 14, + 'page_is_redirect' => 0, + ] + $expected, + 'join_conds' => [ + 'categorylinks' => [ + 0 => 'LEFT JOIN', + 1 => 'cl_from = page_id', + ], + ], + ], $special->getQueryInfo() ); + } + + public function provideTestGetQueryInfoData() { + return [ + [ + "* Stubs\n* Test\n* *\n* * test123", + [ 1 => "page_title not in ( 'Stubs','Test','*','*_test123' )" ] + ], + [ + "Stubs\n* Test\n* *\n* * test123", + [ 1 => "page_title not in ( 'Test','*','*_test123' )" ] + ], + [ + "* StubsTest\n* *\n* * test123", + [ 1 => "page_title not in ( 'StubsTest','*','*_test123' )" ] + ], + [ "", [] ], + [ "\n\n\n", [] ], + [ "\n", [] ], + [ "Test\n*Test2", [ 1 => "page_title not in ( 'Test2' )" ] ], + [ "Test", [] ], + [ "*Test\nTest2", [ 1 => "page_title not in ( 'Test' )" ] ], + [ "Test\nTest2", [] ], + ]; + } +} diff --git a/tests/phpunit/includes/tidy/RemexDriverTest.php b/tests/phpunit/includes/tidy/RemexDriverTest.php new file mode 100644 index 0000000000..5ad8416b81 --- /dev/null +++ b/tests/phpunit/includes/tidy/RemexDriverTest.php @@ -0,0 +1,326 @@ +x

    " + ], + [ + 'No p-wrap of blank node', + " ", + " " + ], + [ + 'p-wrap terminated by div', + "x
    ", + "

    x

    " + ], + [ + 'p-wrap not terminated by span', + "x", + "

    x

    " + ], + [ + 'An element is non-blank and so gets p-wrapped', + "", + "

    " + ], + [ + 'The blank flag is set after a block-level element', + "
    ", + "
    " + ], + [ + 'Blank detection between two block-level elements', + "
    ", + "
    " + ], + [ + 'But p-wrapping of non-blank content works after an element', + "
    x", + "

    x

    " + ], + [ + 'p-wrapping between two block-level elements', + "
    x
    ", + "

    x

    " + ], + [ + 'p-wrap inside blockquote', + "
    x
    ", + "

    x

    " + ], + [ + 'A comment is blank for p-wrapping purposes', + "", + "" + ], + [ + 'A comment is blank even when a p-wrap was opened by a text node', + " ", + " " + ], + [ + 'A comment does not open a p-wrap', + "x", + "

    x

    " + ], + [ + 'A comment does not close a p-wrap', + "x", + "

    x

    " + ], + [ + 'Empty li', + "
    ", + "
    " + ], + [ + 'li with element', + "
    ", + "
    " + ], + [ + 'li with text', + "
    • x
    ", + "
    • x
    " + ], + [ + 'Empty tr', + "
    ", + "
    " + ], + [ + 'Empty p', + "

    \n

    ", + "

    \n

    " + ], + [ + 'No p-wrapping of an inline element which contains a block element (T150317)', + "
    x
    ", + "
    x
    " + ], + [ + 'p-wrapping of an inline element which contains an inline element', + "x", + "

    x

    " + ], + [ + 'p-wrapping is enabled in a blockquote in an inline element', + "
    x
    ", + "

    x

    " + ], + [ + 'All bare text should be p-wrapped even when surrounded by block tags', + "
    x
    y
    z", + "

    x

    y

    z

    " + ], + [ + 'Split tag stack 1', + "x
    y
    z
    ", + "

    x

    y

    z

    " + ], + [ + 'Split tag stack 2', + "
    y
    z
    ", + "
    y

    z

    " + ], + [ + 'Split tag stack 3', + "x
    y
    ", + "

    x

    y
    " + ], + [ + 'Split tag stack 4 (modified to use splittable tag)', + "abc
    d
    e
    ", + "

    abc

    d

    e

    " + ], + [ + "Split tag stack regression check 1", + "x
    y
    ", + "

    x

    y
    " + ], + [ + "Split tag stack regression check 2 (modified to use splittable tag)", + "a
    d
    e
    ", + "

    a

    d

    e

    " + ], + // Simple tests from pwrap.js + [ + 'Simple pwrap test 1', + 'a', + '

    a

    ' + ], + [ + ' is not a splittable tag, but gets p-wrapped in simple wrapping scenarios', + 'a', + '

    a

    ' + ], + [ + 'Simple pwrap test 3', + 'x
    a
    b
    y', + '

    x

    a
    b

    y

    ' + ], + [ + 'Simple pwrap test 4', + 'x
    a
    b
    y', + '

    x

    a
    b

    y

    ' + ], + // Complex tests from pwrap.js + [ + 'Complex pwrap test 1', + 'x
    a
    y
    ', + '

    x

    a

    y

    ' + ], + [ + 'Complex pwrap test 2', + 'abc
    d
    e
    f', + '

    abc

    d

    ef

    ' + ], + [ + 'Complex pwrap test 3', + 'abc
    d
    e
    ', + '

    abc

    d

    e

    ' + ], + [ + 'Complex pwrap test 4', + 'x
    y
    ', + '

    x

    y
    ' + ], + [ + 'Complex pwrap test 5', + 'a
    d
    e
    ', + '

    a

    d

    e

    ' + ], + // phpcs:disable Generic.Files.LineLength + [ + 'Complex pwrap test 6', + 'a
    b
    cd
    e
    f
    g
    ', + // PHP 5 does not allow concatenation in initialisation of a class static variable + '

    a

    b

    cd

    e

    fg

    ' + ], + // phpcs:enable + /* FIXME the second causes a stack split which clones the even + * though no

    is actually generated + [ + 'Complex pwrap test 7', + '

    x
    y
    z
    ', + '
    x
    y
    z
    ' + ], + */ + // New local tests + [ + 'Blank text node after block end', + 'x
    y
    z
    ', + '

    x

    y

    z

    ' + ], + [ + 'Text node fostering (FIXME: wrap missing)', + 'x
    ', + 'x
    ' + ], + [ + 'Blockquote fostering', + '
    x
    ', + '

    x

    ' + ], + [ + 'Block element fostering', + '
    x', + '
    x
    ' + ], + [ + 'Formatting element fostering (FIXME: wrap missing)', + 'x', + 'x
    ' + ], + [ + 'AAA clone of p-wrapped element (FIXME: empty b)', + 'x

    yz

    ', + '

    x

    yz

    ', + ], + [ + 'AAA with fostering (FIXME: wrap missing)', + '1

    23

    ', + '1

    23

    ' + ], + [ + 'AAA causes reparent of p-wrapped text node (T178632)', + '
    x
    ', + '

    x

    ', + ], + [ + 'p-wrap ended by reparenting (T200827)', + '

    ', + '

    ', + ], + [ + 'style tag isn\'t p-wrapped (T186965)', + '', + '', + ], + [ + 'link tag isn\'t p-wrapped (T186965)', + '', + '', + ], + [ + 'style tag doesn\'t split p-wrapping (T208901)', + 'foo bar', + '

    foo bar

    ', + ], + [ + 'link tag doesn\'t split p-wrapping (T208901)', + 'foo bar', + '

    foo bar

    ', + ], + ]; + + public function provider() { + return self::$remexTidyTestData; + } + + /** + * @dataProvider provider + * @covers MediaWiki\Tidy\RemexCompatFormatter + * @covers MediaWiki\Tidy\RemexCompatMunger + * @covers MediaWiki\Tidy\RemexDriver + * @covers MediaWiki\Tidy\RemexMungerData + */ + public function testTidy( $desc, $input, $expected ) { + $r = new MediaWiki\Tidy\RemexDriver( [] ); + $result = $r->tidy( $input ); + $this->assertEquals( $expected, $result, $desc ); + } + + public function html5libProvider() { + $files = json_decode( file_get_contents( __DIR__ . '/html5lib-tests.json' ), true ); + $tests = []; + foreach ( $files as $file => $fileTests ) { + foreach ( $fileTests as $i => $test ) { + $tests[] = [ "$file:$i", $test['data'] ]; + } + } + return $tests; + } + + /** + * This is a quick and dirty test to make sure none of the html5lib tests + * generate exceptions. We don't really know what the expected output is. + * + * @dataProvider html5libProvider + * @coversNothing + */ + public function testHtml5Lib( $desc, $input ) { + $r = new MediaWiki\Tidy\RemexDriver( [] ); + $result = $r->tidy( $input ); + $this->assertTrue( true, $desc ); + } +} diff --git a/tests/phpunit/includes/tidy/html5lib-tests.json b/tests/phpunit/includes/tidy/html5lib-tests.json new file mode 100644 index 0000000000..2b1c3e8cdf --- /dev/null +++ b/tests/phpunit/includes/tidy/html5lib-tests.json @@ -0,0 +1,80692 @@ +{ + "adoption01.dat": [ + { + "data": "

    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,10): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a" + }, + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ], + "html": "

    ", + "noQuirksBodyHtml": "

    " + } + }, + { + "data": "1

    23

    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,12): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "2" + } + ] + }, + { + "text": "3" + } + ] + } + ] + } + ] + } + ], + "html": "1

    23

    ", + "noQuirksBodyHtml": "1

    23

    " + } + }, + { + "data": "1", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,17): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "button": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "button", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "2" + } + ] + }, + { + "text": "3" + } + ] + } + ] + } + ] + } + ], + "html": "1", + "noQuirksBodyHtml": "1" + } + }, + { + "data": "123", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,12): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + }, + { + "tag": "b", + "children": [ + { + "text": "2" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + } + ], + "html": "123", + "noQuirksBodyHtml": "123" + } + }, + { + "data": "1
    2
    34
    5
    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,20): adoption-agency-1.3", + "(1,20): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "div": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "2" + } + ] + }, + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "3" + } + ] + }, + { + "text": "4" + } + ] + }, + { + "text": "5" + } + ] + } + ] + } + ] + } + ], + "html": "1
    2
    34
    5
    ", + "noQuirksBodyHtml": "1
    2
    34
    5
    " + } + }, + { + "data": "1

    23

    ", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,10): unexpected-start-tag-implies-table-voodoo", + "(1,11): unexpected-character-implies-table-voodoo", + "(1,14): unexpected-start-tag-implies-table-voodoo", + "(1,15): unexpected-character-implies-table-voodoo", + "(1,19): unexpected-end-tag-implies-table-voodoo", + "(1,19): adoption-agency-1.3", + "(1,20): unexpected-character-implies-table-voodoo", + "(1,24): unexpected-end-tag-implies-table-voodoo", + "(1,24): eof-in-table" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "p": true, + "table": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "2" + } + ] + }, + { + "text": "3" + } + ] + }, + { + "tag": "table" + } + ] + } + ] + } + ], + "html": "1

    23

    ", + "noQuirksBodyHtml": "1

    23

    " + } + }, + { + "data": "

    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,16): adoption-agency-1.3", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "a" + }, + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "

    ", + "noQuirksBodyHtml": "

    " + } + }, + { + "data": "

    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,16): adoption-agency-1.3", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true, + "a": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "

    ", + "noQuirksBodyHtml": "

    " + } + }, + { + "data": "

    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,16): adoption-agency-1.3", + "(1,16): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "b": true, + "p": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b" + } + ] + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "

    ", + "noQuirksBodyHtml": "

    " + } + }, + { + "data": "

    123

    45", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,30): unexpected-end-tag", + "(1,35): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "s": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "text": "1" + }, + { + "tag": "s", + "attrs": [ + { + "name": "id", + "value": "A" + } + ], + "children": [ + { + "text": "2" + }, + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "B" + } + ], + "children": [ + { + "text": "3" + } + ] + } + ] + } + ] + }, + { + "tag": "s", + "attrs": [ + { + "name": "id", + "value": "A" + } + ], + "children": [ + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "B" + } + ], + "children": [ + { + "text": "4" + } + ] + } + ] + }, + { + "tag": "b", + "attrs": [ + { + "name": "id", + "value": "B" + } + ], + "children": [ + { + "text": "5" + } + ] + } + ] + } + ] + } + ], + "html": "

    123

    45", + "noQuirksBodyHtml": "

    123

    45" + } + }, + { + "data": "13
    2
    ", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,10): unexpected-start-tag-implies-table-voodoo", + "(1,11): unexpected-character-implies-table-voodoo", + "(1,15): unexpected-cell-in-table-body", + "(1,30): unexpected-implied-end-tag-in-table-view" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "text": "1" + } + ] + }, + { + "tag": "a", + "children": [ + { + "text": "3" + } + ] + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "2" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "13
    2
    ", + "noQuirksBodyHtml": "13
    2
    " + } + }, + { + "data": "AC
    B
    ", + "errors": [ + "(1,7): expected-doctype-but-got-start-tag", + "(1,8): unexpected-character-implies-table-voodoo", + "(1,12): unexpected-cell-in-table-body", + "(1,22): unexpected-character-implies-table-voodoo" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "table": true, + "tbody": true, + "tr": true, + "td": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "text": "AC" + }, + { + "tag": "table", + "children": [ + { + "tag": "tbody", + "children": [ + { + "tag": "tr", + "children": [ + { + "tag": "td", + "children": [ + { + "text": "B" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "AC
    B
    ", + "noQuirksBodyHtml": "AC
    B
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,23): unexpected-end-tag", + "(1,23): adoption-agency-1.3" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "a": true, + "svg svg": true, + "svg tr": true, + "svg input": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "svg", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "tr", + "ns": "http://www.w3.org/2000/svg", + "children": [ + { + "tag": "input", + "ns": "http://www.w3.org/2000/svg" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): adoption-agency-1.3", + "(1,65): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "a": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b" + } + ] + }, + { + "tag": "b", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a" + }, + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "div" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "
    ", + "errors": [ + "(1,5): expected-doctype-but-got-start-tag", + "(1,32): adoption-agency-1.3", + "(1,32): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "div": true, + "a": true, + "b": true, + "u": true, + "i": true, + "code": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "u", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "code" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "u", + "children": [ + { + "tag": "i", + "children": [ + { + "tag": "code", + "children": [ + { + "tag": "div", + "children": [ + { + "tag": "a" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "
    ", + "noQuirksBodyHtml": "
    " + } + }, + { + "data": "xy", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ] + } + ] + }, + { + "text": "y" + } + ] + } + ] + } + ], + "html": "xy", + "noQuirksBodyHtml": "xy" + } + }, + { + "data": "

    x", + "errors": [ + "(1,3): expected-doctype-but-got-start-tag", + "(1,18): unexpected-end-tag", + "(1,19): expected-closing-tag-but-got-eof" + ], + "document": { + "props": { + "tags": { + "html": true, + "head": true, + "body": true, + "p": true, + "b": true + } + }, + "tree": [ + { + "tag": "html", + "children": [ + { + "tag": "head" + }, + { + "tag": "body", + "children": [ + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b" + } + ] + } + ] + } + ] + } + ] + }, + { + "tag": "p", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "tag": "b", + "children": [ + { + "text": "x" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "html": "

    x

    ", + "noQuirksBodyHtml": "

    x

    " + } + }, + { + "data": "