3 use Wikimedia\TestingAccessWrapper
;
5 class EtcdConfigTest
extends PHPUnit\Framework\TestCase
{
7 use MediaWikiCoversValidator
;
9 private function createConfigMock( array $options = [] ) {
10 return $this->getMockBuilder( EtcdConfig
::class )
11 ->setConstructorArgs( [ $options +
[
12 'host' => 'etcd-tcp.example.net',
16 ->setMethods( [ 'fetchAllFromEtcd' ] )
20 private static function createEtcdResponse( array $response ) {
27 return array_merge( $baseResponse, $response );
30 private function createSimpleConfigMock( array $config, $index = 0 ) {
31 $mock = $this->createConfigMock();
32 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
33 ->willReturn( self
::createEtcdResponse( [
35 'modifiedIndex' => $index,
41 * @covers EtcdConfig::has
43 public function testHasKnown() {
44 $config = $this->createSimpleConfigMock( [
47 $this->assertSame( true, $config->has( 'known' ) );
51 * @covers EtcdConfig::__construct
52 * @covers EtcdConfig::get
54 public function testGetKnown() {
55 $config = $this->createSimpleConfigMock( [
58 $this->assertSame( 'value', $config->get( 'known' ) );
62 * @covers EtcdConfig::has
64 public function testHasUnknown() {
65 $config = $this->createSimpleConfigMock( [
68 $this->assertSame( false, $config->has( 'unknown' ) );
72 * @covers EtcdConfig::get
74 public function testGetUnknown() {
75 $config = $this->createSimpleConfigMock( [
78 $this->setExpectedException( ConfigException
::class );
79 $config->get( 'unknown' );
83 * @covers EtcdConfig::getModifiedIndex
85 public function testGetModifiedIndex() {
86 $config = $this->createSimpleConfigMock(
87 [ 'some' => 'value' ],
90 $this->assertSame( 123, $config->getModifiedIndex() );
94 * @covers EtcdConfig::__construct
96 public function testConstructCacheObj() {
97 $cache = $this->getMockBuilder( HashBagOStuff
::class )
98 ->setMethods( [ 'get' ] )
100 $cache->expects( $this->once() )->method( 'get' )
102 'config' => [ 'known' => 'from-cache' ],
104 'modifiedIndex' => 123
106 $config = $this->createConfigMock( [ 'cache' => $cache ] );
108 $this->assertSame( 'from-cache', $config->get( 'known' ) );
112 * @covers EtcdConfig::__construct
114 public function testConstructCacheSpec() {
115 $config = $this->createConfigMock( [ 'cache' => [
116 'class' => HashBagOStuff
::class
118 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
119 ->willReturn( self
::createEtcdResponse(
120 [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
122 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
129 * Result: Fetched value
130 * > cache miss | gets lock | backend succeeds
132 * - [x] Cache miss with backend error
133 * Result: ConfigException
134 * > cache miss | gets lock | backend error (no retry)
136 * - [x] Cache hit after retry
137 * Result: Cached value (populated by process holding lock)
138 * > cache miss | no lock | cache retry
141 * Result: Cached value
144 * - [x] Process cache hit
145 * Result: Cached value
146 * > process cache hit
148 * - [x] Cache expired
149 * Result: Fetched value
150 * > cache expired | gets lock | backend succeeds
152 * - [x] Cache expired with backend failure
153 * Result: Cached value (stale)
154 * > cache expired | gets lock | backend fails (allows retry)
156 * - [x] Cache expired and no lock
157 * Result: Cached value (stale)
158 * > cache expired | no lock
160 * Other notable scenarios:
162 * - [ ] Cache miss with backend retry
163 * Result: Fetched value
164 * > cache expired | gets lock | backend failure (allows retry)
168 * @covers EtcdConfig::load
170 public function testLoadCacheMiss() {
172 $cache = $this->getMockBuilder( HashBagOStuff
::class )
173 ->setMethods( [ 'get', 'lock' ] )
176 $cache->expects( $this->once() )->method( 'get' )
177 ->willReturn( false );
179 $cache->expects( $this->once() )->method( 'lock' )
180 ->willReturn( true );
182 // Create config mock
183 $mock = $this->createConfigMock( [
186 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
188 self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
190 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
194 * @covers EtcdConfig::load
196 public function testLoadCacheMissBackendError() {
198 $cache = $this->getMockBuilder( HashBagOStuff
::class )
199 ->setMethods( [ 'get', 'lock' ] )
202 $cache->expects( $this->once() )->method( 'get' )
203 ->willReturn( false );
205 $cache->expects( $this->once() )->method( 'lock' )
206 ->willReturn( true );
208 // Create config mock
209 $mock = $this->createConfigMock( [
212 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
213 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
215 $this->setExpectedException( ConfigException
::class );
220 * @covers EtcdConfig::load
222 public function testLoadCacheMissWithoutLock() {
224 $cache = $this->getMockBuilder( HashBagOStuff
::class )
225 ->setMethods( [ 'get', 'lock' ] )
227 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
228 ->will( $this->onConsecutiveCalls(
229 // .. misses cache first time
231 // .. hits cache on retry
233 'config' => [ 'known' => 'from-cache' ],
235 'modifiedIndex' => 123
239 $cache->expects( $this->once() )->method( 'lock' )
240 ->willReturn( false );
242 // Create config mock
243 $mock = $this->createConfigMock( [
246 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
248 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
252 * @covers EtcdConfig::load
254 public function testLoadCacheHit() {
256 $cache = $this->getMockBuilder( HashBagOStuff
::class )
257 ->setMethods( [ 'get', 'lock' ] )
259 $cache->expects( $this->once() )->method( 'get' )
262 'config' => [ 'known' => 'from-cache' ],
264 'modifiedIndex' => 0,
266 $cache->expects( $this->never() )->method( 'lock' );
268 // Create config mock
269 $mock = $this->createConfigMock( [
272 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
274 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
278 * @covers EtcdConfig::load
280 public function testLoadProcessCacheHit() {
282 $cache = $this->getMockBuilder( HashBagOStuff
::class )
283 ->setMethods( [ 'get', 'lock' ] )
285 $cache->expects( $this->once() )->method( 'get' )
288 'config' => [ 'known' => 'from-cache' ],
290 'modifiedIndex' => 0,
292 $cache->expects( $this->never() )->method( 'lock' );
294 // Create config mock
295 $mock = $this->createConfigMock( [
298 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
300 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
301 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
305 * @covers EtcdConfig::load
307 public function testLoadCacheExpiredLockFetchSucceeded() {
309 $cache = $this->getMockBuilder( HashBagOStuff
::class )
310 ->setMethods( [ 'get', 'lock' ] )
312 $cache->expects( $this->once() )->method( 'get' )->willReturn(
315 'config' => [ 'known' => 'from-cache-expired' ],
317 'modifiedIndex' => 0,
321 $cache->expects( $this->once() )->method( 'lock' )
322 ->willReturn( true );
324 // Create config mock
325 $mock = $this->createConfigMock( [
328 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
329 ->willReturn( self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
331 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
335 * @covers EtcdConfig::load
337 public function testLoadCacheExpiredLockFetchFails() {
339 $cache = $this->getMockBuilder( HashBagOStuff
::class )
340 ->setMethods( [ 'get', 'lock' ] )
342 $cache->expects( $this->once() )->method( 'get' )->willReturn(
345 'config' => [ 'known' => 'from-cache-expired' ],
347 'modifiedIndex' => 0,
351 $cache->expects( $this->once() )->method( 'lock' )
352 ->willReturn( true );
354 // Create config mock
355 $mock = $this->createConfigMock( [
358 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
359 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
361 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
365 * @covers EtcdConfig::load
367 public function testLoadCacheExpiredNoLock() {
369 $cache = $this->getMockBuilder( HashBagOStuff
::class )
370 ->setMethods( [ 'get', 'lock' ] )
372 $cache->expects( $this->once() )->method( 'get' )
373 // .. hits cache (expired value)
375 'config' => [ 'known' => 'from-cache-expired' ],
377 'modifiedIndex' => 0,
380 $cache->expects( $this->once() )->method( 'lock' )
381 ->willReturn( false );
383 // Create config mock
384 $mock = $this->createConfigMock( [
387 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
389 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
392 public static function provideFetchFromServer() {
394 '200 OK - Success' => [
399 'body' => json_encode( [ 'node' => [ 'nodes' => [
401 'key' => '/example/foo',
402 'value' => json_encode( [ 'val' => true ] ),
403 'modifiedIndex' => 123
408 'expect' => self
::createEtcdResponse( [
409 'config' => [ 'foo' => true ], // data
410 'modifiedIndex' => 123
413 '200 OK - Empty dir' => [
418 'body' => json_encode( [ 'node' => [ 'nodes' => [
420 'key' => '/example/foo',
421 'value' => json_encode( [ 'val' => true ] ),
422 'modifiedIndex' => 123
425 'key' => '/example/sub',
427 'modifiedIndex' => 234,
431 'key' => '/example/bar',
432 'value' => json_encode( [ 'val' => false ] ),
433 'modifiedIndex' => 125
438 'expect' => self
::createEtcdResponse( [
439 'config' => [ 'foo' => true, 'bar' => false ], // data
440 'modifiedIndex' => 125 // largest modified index
443 '200 OK - Recursive' => [
448 'body' => json_encode( [ 'node' => [ 'nodes' => [
450 'key' => '/example/a',
452 'modifiedIndex' => 124,
456 'value' => json_encode( [ 'val' => true ] ),
457 'modifiedIndex' => 123,
462 'value' => json_encode( [ 'val' => false ] ),
463 'modifiedIndex' => 123,
470 'expect' => self
::createEtcdResponse( [
471 'config' => [ 'a/b' => true, 'a/c' => false ], // data
472 'modifiedIndex' => 123 // largest modified index
475 '200 OK - Missing nodes at second level' => [
480 'body' => json_encode( [ 'node' => [ 'nodes' => [
482 'key' => '/example/a',
484 'modifiedIndex' => 0,
489 'expect' => self
::createEtcdResponse( [
490 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
493 '200 OK - Directory with non-array "nodes" key' => [
498 'body' => json_encode( [ 'node' => [ 'nodes' => [
500 'key' => '/example/a',
502 'nodes' => 'not an array'
507 'expect' => self
::createEtcdResponse( [
508 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
511 '200 OK - Correctly encoded garbage response' => [
516 'body' => json_encode( [ 'foo' => 'bar' ] ),
519 'expect' => self
::createEtcdResponse( [
520 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
523 '200 OK - Bad value' => [
528 'body' => json_encode( [ 'node' => [ 'nodes' => [
530 'key' => '/example/foo',
531 'value' => ';"broken{value',
532 'modifiedIndex' => 123,
537 'expect' => self
::createEtcdResponse( [
538 'error' => "Failed to parse value for 'foo'.",
541 '200 OK - Empty node list' => [
546 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
549 'expect' => self
::createEtcdResponse( [
550 'config' => [], // data
553 '200 OK - Invalid JSON' => [
557 'headers' => [ 'content-length' => 0 ],
559 'error' => '(curl error: no status set)',
561 'expect' => self
::createEtcdResponse( [
562 'error' => "Error unserializing JSON response.",
568 'reason' => 'Not Found',
569 'headers' => [ 'content-length' => 0 ],
573 'expect' => self
::createEtcdResponse( [
574 'error' => 'HTTP 404 (Not Found)',
577 '400 Bad Request - custom error' => [
580 'reason' => 'Bad Request',
581 'headers' => [ 'content-length' => 0 ],
583 'error' => 'No good reason',
585 'expect' => self
::createEtcdResponse( [
586 'error' => 'No good reason',
587 'retry' => true, // retry
594 * @covers EtcdConfig::fetchAllFromEtcdServer
595 * @covers EtcdConfig::unserialize
596 * @covers EtcdConfig::parseResponse
597 * @covers EtcdConfig::parseDirectory
598 * @covers EtcdConfigParseError
599 * @dataProvider provideFetchFromServer
601 public function testFetchFromServer( array $httpResponse, array $expected ) {
602 $http = $this->getMockBuilder( MultiHttpClient
::class )
603 ->disableOriginalConstructor()
605 $http->expects( $this->once() )->method( 'run' )
606 ->willReturn( array_values( $httpResponse ) );
608 $conf = $this->getMockBuilder( EtcdConfig
::class )
609 ->disableOriginalConstructor()
611 // Access for protected member and method
612 $conf = TestingAccessWrapper
::newFromObject( $conf );
617 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )