3 use Wikimedia\TestingAccessWrapper
;
5 class EtcdConfigTest
extends PHPUnit\Framework\TestCase
{
7 use MediaWikiCoversValidator
;
8 use PHPUnit4And6Compat
;
10 private function createConfigMock( array $options = [] ) {
11 return $this->getMockBuilder( EtcdConfig
::class )
12 ->setConstructorArgs( [ $options +
[
13 'host' => 'etcd-tcp.example.net',
17 ->setMethods( [ 'fetchAllFromEtcd' ] )
21 private static function createEtcdResponse( array $response ) {
28 return array_merge( $baseResponse, $response );
31 private function createSimpleConfigMock( array $config, $index = 0 ) {
32 $mock = $this->createConfigMock();
33 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
34 ->willReturn( self
::createEtcdResponse( [
36 'modifiedIndex' => $index,
42 * @covers EtcdConfig::has
44 public function testHasKnown() {
45 $config = $this->createSimpleConfigMock( [
48 $this->assertSame( true, $config->has( 'known' ) );
52 * @covers EtcdConfig::__construct
53 * @covers EtcdConfig::get
55 public function testGetKnown() {
56 $config = $this->createSimpleConfigMock( [
59 $this->assertSame( 'value', $config->get( 'known' ) );
63 * @covers EtcdConfig::has
65 public function testHasUnknown() {
66 $config = $this->createSimpleConfigMock( [
69 $this->assertSame( false, $config->has( 'unknown' ) );
73 * @covers EtcdConfig::get
75 public function testGetUnknown() {
76 $config = $this->createSimpleConfigMock( [
79 $this->setExpectedException( ConfigException
::class );
80 $config->get( 'unknown' );
84 * @covers EtcdConfig::getModifiedIndex
86 public function testGetModifiedIndex() {
87 $config = $this->createSimpleConfigMock(
88 [ 'some' => 'value' ],
91 $this->assertSame( 123, $config->getModifiedIndex() );
95 * @covers EtcdConfig::__construct
97 public function testConstructCacheObj() {
98 $cache = $this->getMockBuilder( HashBagOStuff
::class )
99 ->setMethods( [ 'get' ] )
101 $cache->expects( $this->once() )->method( 'get' )
103 'config' => [ 'known' => 'from-cache' ],
105 'modifiedIndex' => 123
107 $config = $this->createConfigMock( [ 'cache' => $cache ] );
109 $this->assertSame( 'from-cache', $config->get( 'known' ) );
113 * @covers EtcdConfig::__construct
115 public function testConstructCacheSpec() {
116 $config = $this->createConfigMock( [ 'cache' => [
117 'class' => HashBagOStuff
::class
119 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
120 ->willReturn( self
::createEtcdResponse(
121 [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
123 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
130 * Result: Fetched value
131 * > cache miss | gets lock | backend succeeds
133 * - [x] Cache miss with backend error
134 * Result: ConfigException
135 * > cache miss | gets lock | backend error (no retry)
137 * - [x] Cache hit after retry
138 * Result: Cached value (populated by process holding lock)
139 * > cache miss | no lock | cache retry
142 * Result: Cached value
145 * - [x] Process cache hit
146 * Result: Cached value
147 * > process cache hit
149 * - [x] Cache expired
150 * Result: Fetched value
151 * > cache expired | gets lock | backend succeeds
153 * - [x] Cache expired with backend failure
154 * Result: Cached value (stale)
155 * > cache expired | gets lock | backend fails (allows retry)
157 * - [x] Cache expired and no lock
158 * Result: Cached value (stale)
159 * > cache expired | no lock
161 * Other notable scenarios:
163 * - [ ] Cache miss with backend retry
164 * Result: Fetched value
165 * > cache expired | gets lock | backend failure (allows retry)
169 * @covers EtcdConfig::load
171 public function testLoadCacheMiss() {
173 $cache = $this->getMockBuilder( HashBagOStuff
::class )
174 ->setMethods( [ 'get', 'lock' ] )
177 $cache->expects( $this->once() )->method( 'get' )
178 ->willReturn( false );
180 $cache->expects( $this->once() )->method( 'lock' )
181 ->willReturn( true );
183 // Create config mock
184 $mock = $this->createConfigMock( [
187 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
189 self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
191 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
195 * @covers EtcdConfig::load
197 public function testLoadCacheMissBackendError() {
199 $cache = $this->getMockBuilder( HashBagOStuff
::class )
200 ->setMethods( [ 'get', 'lock' ] )
203 $cache->expects( $this->once() )->method( 'get' )
204 ->willReturn( false );
206 $cache->expects( $this->once() )->method( 'lock' )
207 ->willReturn( true );
209 // Create config mock
210 $mock = $this->createConfigMock( [
213 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
214 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
216 $this->setExpectedException( ConfigException
::class );
221 * @covers EtcdConfig::load
223 public function testLoadCacheMissWithoutLock() {
225 $cache = $this->getMockBuilder( HashBagOStuff
::class )
226 ->setMethods( [ 'get', 'lock' ] )
228 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
229 ->will( $this->onConsecutiveCalls(
230 // .. misses cache first time
232 // .. hits cache on retry
234 'config' => [ 'known' => 'from-cache' ],
236 'modifiedIndex' => 123
240 $cache->expects( $this->once() )->method( 'lock' )
241 ->willReturn( false );
243 // Create config mock
244 $mock = $this->createConfigMock( [
247 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
249 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
253 * @covers EtcdConfig::load
255 public function testLoadCacheHit() {
257 $cache = $this->getMockBuilder( HashBagOStuff
::class )
258 ->setMethods( [ 'get', 'lock' ] )
260 $cache->expects( $this->once() )->method( 'get' )
263 'config' => [ 'known' => 'from-cache' ],
265 'modifiedIndex' => 0,
267 $cache->expects( $this->never() )->method( 'lock' );
269 // Create config mock
270 $mock = $this->createConfigMock( [
273 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
275 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
279 * @covers EtcdConfig::load
281 public function testLoadProcessCacheHit() {
283 $cache = $this->getMockBuilder( HashBagOStuff
::class )
284 ->setMethods( [ 'get', 'lock' ] )
286 $cache->expects( $this->once() )->method( 'get' )
289 'config' => [ 'known' => 'from-cache' ],
291 'modifiedIndex' => 0,
293 $cache->expects( $this->never() )->method( 'lock' );
295 // Create config mock
296 $mock = $this->createConfigMock( [
299 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
301 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
302 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
306 * @covers EtcdConfig::load
308 public function testLoadCacheExpiredLockFetchSucceeded() {
310 $cache = $this->getMockBuilder( HashBagOStuff
::class )
311 ->setMethods( [ 'get', 'lock' ] )
313 $cache->expects( $this->once() )->method( 'get' )->willReturn(
316 'config' => [ 'known' => 'from-cache-expired' ],
318 'modifiedIndex' => 0,
322 $cache->expects( $this->once() )->method( 'lock' )
323 ->willReturn( true );
325 // Create config mock
326 $mock = $this->createConfigMock( [
329 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
330 ->willReturn( self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
332 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
336 * @covers EtcdConfig::load
338 public function testLoadCacheExpiredLockFetchFails() {
340 $cache = $this->getMockBuilder( HashBagOStuff
::class )
341 ->setMethods( [ 'get', 'lock' ] )
343 $cache->expects( $this->once() )->method( 'get' )->willReturn(
346 'config' => [ 'known' => 'from-cache-expired' ],
348 'modifiedIndex' => 0,
352 $cache->expects( $this->once() )->method( 'lock' )
353 ->willReturn( true );
355 // Create config mock
356 $mock = $this->createConfigMock( [
359 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
360 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
362 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
366 * @covers EtcdConfig::load
368 public function testLoadCacheExpiredNoLock() {
370 $cache = $this->getMockBuilder( HashBagOStuff
::class )
371 ->setMethods( [ 'get', 'lock' ] )
373 $cache->expects( $this->once() )->method( 'get' )
374 // .. hits cache (expired value)
376 'config' => [ 'known' => 'from-cache-expired' ],
378 'modifiedIndex' => 0,
381 $cache->expects( $this->once() )->method( 'lock' )
382 ->willReturn( false );
384 // Create config mock
385 $mock = $this->createConfigMock( [
388 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
390 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
393 public static function provideFetchFromServer() {
395 '200 OK - Success' => [
400 'body' => json_encode( [ 'node' => [ 'nodes' => [
402 'key' => '/example/foo',
403 'value' => json_encode( [ 'val' => true ] ),
404 'modifiedIndex' => 123
409 'expect' => self
::createEtcdResponse( [
410 'config' => [ 'foo' => true ], // data
411 'modifiedIndex' => 123
414 '200 OK - Empty dir' => [
419 'body' => json_encode( [ 'node' => [ 'nodes' => [
421 'key' => '/example/foo',
422 'value' => json_encode( [ 'val' => true ] ),
423 'modifiedIndex' => 123
426 'key' => '/example/sub',
428 'modifiedIndex' => 234,
432 'key' => '/example/bar',
433 'value' => json_encode( [ 'val' => false ] ),
434 'modifiedIndex' => 125
439 'expect' => self
::createEtcdResponse( [
440 'config' => [ 'foo' => true, 'bar' => false ], // data
441 'modifiedIndex' => 125 // largest modified index
444 '200 OK - Recursive' => [
449 'body' => json_encode( [ 'node' => [ 'nodes' => [
451 'key' => '/example/a',
453 'modifiedIndex' => 124,
457 'value' => json_encode( [ 'val' => true ] ),
458 'modifiedIndex' => 123,
463 'value' => json_encode( [ 'val' => false ] ),
464 'modifiedIndex' => 123,
471 'expect' => self
::createEtcdResponse( [
472 'config' => [ 'a/b' => true, 'a/c' => false ], // data
473 'modifiedIndex' => 123 // largest modified index
476 '200 OK - Missing nodes at second level' => [
481 'body' => json_encode( [ 'node' => [ 'nodes' => [
483 'key' => '/example/a',
485 'modifiedIndex' => 0,
490 'expect' => self
::createEtcdResponse( [
491 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
494 '200 OK - Directory with non-array "nodes" key' => [
499 'body' => json_encode( [ 'node' => [ 'nodes' => [
501 'key' => '/example/a',
503 'nodes' => 'not an array'
508 'expect' => self
::createEtcdResponse( [
509 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
512 '200 OK - Correctly encoded garbage response' => [
517 'body' => json_encode( [ 'foo' => 'bar' ] ),
520 'expect' => self
::createEtcdResponse( [
521 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
524 '200 OK - Bad value' => [
529 'body' => json_encode( [ 'node' => [ 'nodes' => [
531 'key' => '/example/foo',
532 'value' => ';"broken{value',
533 'modifiedIndex' => 123,
538 'expect' => self
::createEtcdResponse( [
539 'error' => "Failed to parse value for 'foo'.",
542 '200 OK - Empty node list' => [
547 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
550 'expect' => self
::createEtcdResponse( [
551 'config' => [], // data
554 '200 OK - Invalid JSON' => [
558 'headers' => [ 'content-length' => 0 ],
560 'error' => '(curl error: no status set)',
562 'expect' => self
::createEtcdResponse( [
563 'error' => "Error unserializing JSON response.",
569 'reason' => 'Not Found',
570 'headers' => [ 'content-length' => 0 ],
574 'expect' => self
::createEtcdResponse( [
575 'error' => 'HTTP 404 (Not Found)',
578 '400 Bad Request - custom error' => [
581 'reason' => 'Bad Request',
582 'headers' => [ 'content-length' => 0 ],
584 'error' => 'No good reason',
586 'expect' => self
::createEtcdResponse( [
587 'error' => 'No good reason',
588 'retry' => true, // retry
595 * @covers EtcdConfig::fetchAllFromEtcdServer
596 * @covers EtcdConfig::unserialize
597 * @covers EtcdConfig::parseResponse
598 * @covers EtcdConfig::parseDirectory
599 * @covers EtcdConfigParseError
600 * @dataProvider provideFetchFromServer
602 public function testFetchFromServer( array $httpResponse, array $expected ) {
603 $http = $this->getMockBuilder( MultiHttpClient
::class )
604 ->disableOriginalConstructor()
606 $http->expects( $this->once() )->method( 'run' )
607 ->willReturn( array_values( $httpResponse ) );
609 $conf = $this->getMockBuilder( EtcdConfig
::class )
610 ->disableOriginalConstructor()
612 // Access for protected member and method
613 $conf = TestingAccessWrapper
::newFromObject( $conf );
618 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )