summaryrefslogtreecommitdiff
path: root/www/wiki/tests/phpunit/includes/config
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/tests/phpunit/includes/config')
-rw-r--r--www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php168
-rw-r--r--www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php621
-rw-r--r--www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php97
-rw-r--r--www/wiki/tests/phpunit/includes/config/HashConfigTest.php63
-rw-r--r--www/wiki/tests/phpunit/includes/config/MultiConfigTest.php39
5 files changed, 988 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php b/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php
new file mode 100644
index 00000000..ea747afa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/ConfigFactoryTest.php
@@ -0,0 +1,168 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+class ConfigFactoryTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ConfigFactory::register
+ */
+ public function testRegister() {
+ $factory = new ConfigFactory();
+ $factory->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/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php b/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php
new file mode 100644
index 00000000..3eecf827
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/EtcdConfigTest.php
@@ -0,0 +1,621 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class EtcdConfigTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+ use PHPUnit4And6Compat;
+
+ private function createConfigMock( array $options = [] ) {
+ return $this->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/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php b/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php
new file mode 100644
index 00000000..db5f73d4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/GlobalVarConfigTest.php
@@ -0,0 +1,97 @@
+<?php
+
+class GlobalVarConfigTest extends MediaWikiTestCase {
+
+ /**
+ * @covers GlobalVarConfig::newInstance
+ */
+ public function testNewInstance() {
+ $config = GlobalVarConfig::newInstance();
+ $this->assertInstanceOf( GlobalVarConfig::class, $config );
+ $this->maybeStashGlobal( 'wgBaz' );
+ $GLOBALS['wgBaz'] = 'somevalue';
+ // Check prefix is set to 'wg'
+ $this->assertEquals( 'somevalue', $config->get( 'Baz' ) );
+ }
+
+ /**
+ * @covers GlobalVarConfig::__construct
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $prefix ) {
+ $var = $prefix . 'GlobalVarConfigTest';
+ $rand = wfRandomString();
+ $this->maybeStashGlobal( $var );
+ $GLOBALS[$var] = $rand;
+ $config = new GlobalVarConfig( $prefix );
+ $this->assertInstanceOf( GlobalVarConfig::class, $config );
+ $this->assertEquals( $rand, $config->get( 'GlobalVarConfigTest' ) );
+ }
+
+ public static function provideConstructor() {
+ return [
+ [ 'wg' ],
+ [ 'ef' ],
+ [ 'smw' ],
+ [ 'blahblahblahblah' ],
+ [ '' ],
+ ];
+ }
+
+ /**
+ * @covers GlobalVarConfig::has
+ * @covers GlobalVarConfig::hasWithPrefix
+ */
+ public function testHas() {
+ $this->maybeStashGlobal( 'wgGlobalVarConfigTestHas' );
+ $GLOBALS['wgGlobalVarConfigTestHas'] = wfRandomString();
+ $this->maybeStashGlobal( 'wgGlobalVarConfigTestNotHas' );
+ $config = new GlobalVarConfig();
+ $this->assertTrue( $config->has( 'GlobalVarConfigTestHas' ) );
+ $this->assertFalse( $config->has( 'GlobalVarConfigTestNotHas' ) );
+ }
+
+ public static function provideGet() {
+ $set = [
+ 'wgSomething' => 'default1',
+ 'wgFoo' => 'default2',
+ 'efVariable' => 'default3',
+ 'BAR' => 'default4',
+ ];
+
+ foreach ( $set as $var => $value ) {
+ $GLOBALS[$var] = $value;
+ }
+
+ return [
+ [ 'Something', 'wg', 'default1' ],
+ [ 'Foo', 'wg', 'default2' ],
+ [ 'Variable', 'ef', 'default3' ],
+ [ 'BAR', '', 'default4' ],
+ [ 'ThisGlobalWasNotSetAbove', 'wg', false ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideGet
+ * @covers GlobalVarConfig::get
+ * @covers GlobalVarConfig::getWithPrefix
+ * @param string $name
+ * @param string $prefix
+ * @param string $expected
+ */
+ public function testGet( $name, $prefix, $expected ) {
+ $config = new GlobalVarConfig( $prefix );
+ if ( $expected === false ) {
+ $this->setExpectedException( ConfigException::class, 'GlobalVarConfig::get: undefined option:' );
+ }
+ $this->assertEquals( $config->get( $name ), $expected );
+ }
+
+ private function maybeStashGlobal( $var ) {
+ if ( array_key_exists( $var, $GLOBALS ) ) {
+ // Will be reset after this test is over
+ $this->stashMwGlobals( $var );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/config/HashConfigTest.php b/www/wiki/tests/phpunit/includes/config/HashConfigTest.php
new file mode 100644
index 00000000..bac8311c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/HashConfigTest.php
@@ -0,0 +1,63 @@
+<?php
+
+class HashConfigTest extends MediaWikiTestCase {
+
+ /**
+ * @covers HashConfig::newInstance
+ */
+ public function testNewInstance() {
+ $conf = HashConfig::newInstance();
+ $this->assertInstanceOf( HashConfig::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/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php b/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php
new file mode 100644
index 00000000..fc283951
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/config/MultiConfigTest.php
@@ -0,0 +1,39 @@
+<?php
+
+class MultiConfigTest extends MediaWikiTestCase {
+
+ /**
+ * Tests that settings are fetched in the right order
+ *
+ * @covers MultiConfig::__construct
+ * @covers MultiConfig::get
+ */
+ public function testGet() {
+ $multi = new MultiConfig( [
+ new HashConfig( [ 'foo' => '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' ) );
+ }
+}