3 use Wikimedia\TestingAccessWrapper
;
5 class EtcdConfigTest
extends MediaWikiUnitTestCase
{
7 private function createConfigMock( array $options = [] ) {
8 return $this->getMockBuilder( EtcdConfig
::class )
9 ->setConstructorArgs( [ $options +
[
10 'host' => 'etcd-tcp.example.net',
14 ->setMethods( [ 'fetchAllFromEtcd' ] )
18 private static function createEtcdResponse( array $response ) {
25 return array_merge( $baseResponse, $response );
28 private function createSimpleConfigMock( array $config, $index = 0 ) {
29 $mock = $this->createConfigMock();
30 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
31 ->willReturn( self
::createEtcdResponse( [
33 'modifiedIndex' => $index,
39 * @covers EtcdConfig::has
41 public function testHasKnown() {
42 $config = $this->createSimpleConfigMock( [
45 $this->assertSame( true, $config->has( 'known' ) );
49 * @covers EtcdConfig::__construct
50 * @covers EtcdConfig::get
52 public function testGetKnown() {
53 $config = $this->createSimpleConfigMock( [
56 $this->assertSame( 'value', $config->get( 'known' ) );
60 * @covers EtcdConfig::has
62 public function testHasUnknown() {
63 $config = $this->createSimpleConfigMock( [
66 $this->assertSame( false, $config->has( 'unknown' ) );
70 * @covers EtcdConfig::get
72 public function testGetUnknown() {
73 $config = $this->createSimpleConfigMock( [
76 $this->setExpectedException( ConfigException
::class );
77 $config->get( 'unknown' );
81 * @covers EtcdConfig::getModifiedIndex
83 public function testGetModifiedIndex() {
84 $config = $this->createSimpleConfigMock(
85 [ 'some' => 'value' ],
88 $this->assertSame( 123, $config->getModifiedIndex() );
92 * @covers EtcdConfig::__construct
94 public function testConstructCacheObj() {
95 $cache = $this->getMockBuilder( HashBagOStuff
::class )
96 ->setMethods( [ 'get' ] )
98 $cache->expects( $this->once() )->method( 'get' )
100 'config' => [ 'known' => 'from-cache' ],
102 'modifiedIndex' => 123
104 $config = $this->createConfigMock( [ 'cache' => $cache ] );
106 $this->assertSame( 'from-cache', $config->get( 'known' ) );
110 * @covers EtcdConfig::__construct
112 public function testConstructCacheSpec() {
113 $config = $this->createConfigMock( [ 'cache' => [
114 'class' => HashBagOStuff
::class
116 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
117 ->willReturn( self
::createEtcdResponse(
118 [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
120 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
127 * Result: Fetched value
128 * > cache miss | gets lock | backend succeeds
130 * - [x] Cache miss with backend error
131 * Result: ConfigException
132 * > cache miss | gets lock | backend error (no retry)
134 * - [x] Cache hit after retry
135 * Result: Cached value (populated by process holding lock)
136 * > cache miss | no lock | cache retry
139 * Result: Cached value
142 * - [x] Process cache hit
143 * Result: Cached value
144 * > process cache hit
146 * - [x] Cache expired
147 * Result: Fetched value
148 * > cache expired | gets lock | backend succeeds
150 * - [x] Cache expired with backend failure
151 * Result: Cached value (stale)
152 * > cache expired | gets lock | backend fails (allows retry)
154 * - [x] Cache expired and no lock
155 * Result: Cached value (stale)
156 * > cache expired | no lock
158 * Other notable scenarios:
160 * - [ ] Cache miss with backend retry
161 * Result: Fetched value
162 * > cache expired | gets lock | backend failure (allows retry)
166 * @covers EtcdConfig::load
168 public function testLoadCacheMiss() {
170 $cache = $this->getMockBuilder( HashBagOStuff
::class )
171 ->setMethods( [ 'get', 'lock' ] )
174 $cache->expects( $this->once() )->method( 'get' )
175 ->willReturn( false );
177 $cache->expects( $this->once() )->method( 'lock' )
178 ->willReturn( true );
180 // Create config mock
181 $mock = $this->createConfigMock( [
184 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
186 self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
188 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
192 * @covers EtcdConfig::load
194 public function testLoadCacheMissBackendError() {
196 $cache = $this->getMockBuilder( HashBagOStuff
::class )
197 ->setMethods( [ 'get', 'lock' ] )
200 $cache->expects( $this->once() )->method( 'get' )
201 ->willReturn( false );
203 $cache->expects( $this->once() )->method( 'lock' )
204 ->willReturn( true );
206 // Create config mock
207 $mock = $this->createConfigMock( [
210 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
211 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
213 $this->setExpectedException( ConfigException
::class );
218 * @covers EtcdConfig::load
220 public function testLoadCacheMissWithoutLock() {
222 $cache = $this->getMockBuilder( HashBagOStuff
::class )
223 ->setMethods( [ 'get', 'lock' ] )
225 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
226 ->will( $this->onConsecutiveCalls(
227 // .. misses cache first time
229 // .. hits cache on retry
231 'config' => [ 'known' => 'from-cache' ],
233 'modifiedIndex' => 123
237 $cache->expects( $this->once() )->method( 'lock' )
238 ->willReturn( false );
240 // Create config mock
241 $mock = $this->createConfigMock( [
244 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
246 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
250 * @covers EtcdConfig::load
252 public function testLoadCacheHit() {
254 $cache = $this->getMockBuilder( HashBagOStuff
::class )
255 ->setMethods( [ 'get', 'lock' ] )
257 $cache->expects( $this->once() )->method( 'get' )
260 'config' => [ 'known' => 'from-cache' ],
262 'modifiedIndex' => 0,
264 $cache->expects( $this->never() )->method( 'lock' );
266 // Create config mock
267 $mock = $this->createConfigMock( [
270 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
272 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
276 * @covers EtcdConfig::load
278 public function testLoadProcessCacheHit() {
280 $cache = $this->getMockBuilder( HashBagOStuff
::class )
281 ->setMethods( [ 'get', 'lock' ] )
283 $cache->expects( $this->once() )->method( 'get' )
286 'config' => [ 'known' => 'from-cache' ],
288 'modifiedIndex' => 0,
290 $cache->expects( $this->never() )->method( 'lock' );
292 // Create config mock
293 $mock = $this->createConfigMock( [
296 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
298 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
299 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
303 * @covers EtcdConfig::load
305 public function testLoadCacheExpiredLockFetchSucceeded() {
307 $cache = $this->getMockBuilder( HashBagOStuff
::class )
308 ->setMethods( [ 'get', 'lock' ] )
310 $cache->expects( $this->once() )->method( 'get' )->willReturn(
313 'config' => [ 'known' => 'from-cache-expired' ],
315 'modifiedIndex' => 0,
319 $cache->expects( $this->once() )->method( 'lock' )
320 ->willReturn( true );
322 // Create config mock
323 $mock = $this->createConfigMock( [
326 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
327 ->willReturn( self
::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
329 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
333 * @covers EtcdConfig::load
335 public function testLoadCacheExpiredLockFetchFails() {
337 $cache = $this->getMockBuilder( HashBagOStuff
::class )
338 ->setMethods( [ 'get', 'lock' ] )
340 $cache->expects( $this->once() )->method( 'get' )->willReturn(
343 'config' => [ 'known' => 'from-cache-expired' ],
345 'modifiedIndex' => 0,
349 $cache->expects( $this->once() )->method( 'lock' )
350 ->willReturn( true );
352 // Create config mock
353 $mock = $this->createConfigMock( [
356 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
357 ->willReturn( self
::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
359 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
363 * @covers EtcdConfig::load
365 public function testLoadCacheExpiredNoLock() {
367 $cache = $this->getMockBuilder( HashBagOStuff
::class )
368 ->setMethods( [ 'get', 'lock' ] )
370 $cache->expects( $this->once() )->method( 'get' )
371 // .. hits cache (expired value)
373 'config' => [ 'known' => 'from-cache-expired' ],
375 'modifiedIndex' => 0,
378 $cache->expects( $this->once() )->method( 'lock' )
379 ->willReturn( false );
381 // Create config mock
382 $mock = $this->createConfigMock( [
385 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
387 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
390 public static function provideFetchFromServer() {
392 '200 OK - Success' => [
397 'body' => json_encode( [ 'node' => [ 'nodes' => [
399 'key' => '/example/foo',
400 'value' => json_encode( [ 'val' => true ] ),
401 'modifiedIndex' => 123
406 'expect' => self
::createEtcdResponse( [
407 'config' => [ 'foo' => true ], // data
408 'modifiedIndex' => 123
411 '200 OK - Empty dir' => [
416 'body' => json_encode( [ 'node' => [ 'nodes' => [
418 'key' => '/example/foo',
419 'value' => json_encode( [ 'val' => true ] ),
420 'modifiedIndex' => 123
423 'key' => '/example/sub',
425 'modifiedIndex' => 234,
429 'key' => '/example/bar',
430 'value' => json_encode( [ 'val' => false ] ),
431 'modifiedIndex' => 125
436 'expect' => self
::createEtcdResponse( [
437 'config' => [ 'foo' => true, 'bar' => false ], // data
438 'modifiedIndex' => 125 // largest modified index
441 '200 OK - Recursive' => [
446 'body' => json_encode( [ 'node' => [ 'nodes' => [
448 'key' => '/example/a',
450 'modifiedIndex' => 124,
454 'value' => json_encode( [ 'val' => true ] ),
455 'modifiedIndex' => 123,
460 'value' => json_encode( [ 'val' => false ] ),
461 'modifiedIndex' => 123,
468 'expect' => self
::createEtcdResponse( [
469 'config' => [ 'a/b' => true, 'a/c' => false ], // data
470 'modifiedIndex' => 123 // largest modified index
473 '200 OK - Missing nodes at second level' => [
478 'body' => json_encode( [ 'node' => [ 'nodes' => [
480 'key' => '/example/a',
482 'modifiedIndex' => 0,
487 'expect' => self
::createEtcdResponse( [
488 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
491 '200 OK - Directory with non-array "nodes" key' => [
496 'body' => json_encode( [ 'node' => [ 'nodes' => [
498 'key' => '/example/a',
500 'nodes' => 'not an array'
505 'expect' => self
::createEtcdResponse( [
506 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
509 '200 OK - Correctly encoded garbage response' => [
514 'body' => json_encode( [ 'foo' => 'bar' ] ),
517 'expect' => self
::createEtcdResponse( [
518 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
521 '200 OK - Bad value' => [
526 'body' => json_encode( [ 'node' => [ 'nodes' => [
528 'key' => '/example/foo',
529 'value' => ';"broken{value',
530 'modifiedIndex' => 123,
535 'expect' => self
::createEtcdResponse( [
536 'error' => "Failed to parse value for 'foo'.",
539 '200 OK - Empty node list' => [
544 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
547 'expect' => self
::createEtcdResponse( [
548 'config' => [], // data
551 '200 OK - Invalid JSON' => [
555 'headers' => [ 'content-length' => 0 ],
557 'error' => '(curl error: no status set)',
559 'expect' => self
::createEtcdResponse( [
560 'error' => "Error unserializing JSON response.",
566 'reason' => 'Not Found',
567 'headers' => [ 'content-length' => 0 ],
571 'expect' => self
::createEtcdResponse( [
572 'error' => 'HTTP 404 (Not Found)',
575 '400 Bad Request - custom error' => [
578 'reason' => 'Bad Request',
579 'headers' => [ 'content-length' => 0 ],
581 'error' => 'No good reason',
583 'expect' => self
::createEtcdResponse( [
584 'error' => 'No good reason',
585 'retry' => true, // retry
592 * @covers EtcdConfig::fetchAllFromEtcdServer
593 * @covers EtcdConfig::unserialize
594 * @covers EtcdConfig::parseResponse
595 * @covers EtcdConfig::parseDirectory
596 * @covers EtcdConfigParseError
597 * @dataProvider provideFetchFromServer
599 public function testFetchFromServer( array $httpResponse, array $expected ) {
600 $http = $this->getMockBuilder( MultiHttpClient
::class )
601 ->disableOriginalConstructor()
603 $http->expects( $this->once() )->method( 'run' )
604 ->willReturn( array_values( $httpResponse ) );
606 $conf = $this->getMockBuilder( EtcdConfig
::class )
607 ->disableOriginalConstructor()
609 // Access for protected member and method
610 $conf = TestingAccessWrapper
::newFromObject( $conf );
615 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )