MediaWiki REL1_31
EtcdConfigTest.php
Go to the documentation of this file.
1<?php
2
3use Wikimedia\TestingAccessWrapper;
4
5class EtcdConfigTest extends PHPUnit\Framework\TestCase {
6
7 use MediaWikiCoversValidator;
8 use PHPUnit4And6Compat;
9
10 private function createConfigMock( array $options = [] ) {
11 return $this->getMockBuilder( EtcdConfig::class )
12 ->setConstructorArgs( [ $options + [
13 'host' => 'etcd-tcp.example.net',
14 'directory' => '/',
15 'timeout' => 0.1,
16 ] ] )
17 ->setMethods( [ 'fetchAllFromEtcd' ] )
18 ->getMock();
19 }
20
21 private static function createEtcdResponse( array $response ) {
22 $baseResponse = [
23 'config' => null,
24 'error' => null,
25 'retry' => false,
26 'modifiedIndex' => 0,
27 ];
28 return array_merge( $baseResponse, $response );
29 }
30
31 private function createSimpleConfigMock( array $config, $index = 0 ) {
32 $mock = $this->createConfigMock();
33 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
34 ->willReturn( self::createEtcdResponse( [
35 'config' => $config,
36 'modifiedIndex' => $index,
37 ] ) );
38 return $mock;
39 }
40
44 public function testHasKnown() {
45 $config = $this->createSimpleConfigMock( [
46 'known' => 'value'
47 ] );
48 $this->assertSame( true, $config->has( 'known' ) );
49 }
50
55 public function testGetKnown() {
56 $config = $this->createSimpleConfigMock( [
57 'known' => 'value'
58 ] );
59 $this->assertSame( 'value', $config->get( 'known' ) );
60 }
61
65 public function testHasUnknown() {
66 $config = $this->createSimpleConfigMock( [
67 'known' => 'value'
68 ] );
69 $this->assertSame( false, $config->has( 'unknown' ) );
70 }
71
75 public function testGetUnknown() {
76 $config = $this->createSimpleConfigMock( [
77 'known' => 'value'
78 ] );
79 $this->setExpectedException( ConfigException::class );
80 $config->get( 'unknown' );
81 }
82
86 public function testGetModifiedIndex() {
87 $config = $this->createSimpleConfigMock(
88 [ 'some' => 'value' ],
89 123
90 );
91 $this->assertSame( 123, $config->getModifiedIndex() );
92 }
93
97 public function testConstructCacheObj() {
98 $cache = $this->getMockBuilder( HashBagOStuff::class )
99 ->setMethods( [ 'get' ] )
100 ->getMock();
101 $cache->expects( $this->once() )->method( 'get' )
102 ->willReturn( [
103 'config' => [ 'known' => 'from-cache' ],
104 'expires' => INF,
105 'modifiedIndex' => 123
106 ] );
107 $config = $this->createConfigMock( [ 'cache' => $cache ] );
108
109 $this->assertSame( 'from-cache', $config->get( 'known' ) );
110 }
111
115 public function testConstructCacheSpec() {
116 $config = $this->createConfigMock( [ 'cache' => [
117 'class' => HashBagOStuff::class
118 ] ] );
119 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
120 ->willReturn( self::createEtcdResponse(
121 [ 'config' => [ 'known' => 'from-fetch' ], ] ) );
122
123 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
124 }
125
171 public function testLoadCacheMiss() {
172 // Create cache mock
173 $cache = $this->getMockBuilder( HashBagOStuff::class )
174 ->setMethods( [ 'get', 'lock' ] )
175 ->getMock();
176 // .. misses cache
177 $cache->expects( $this->once() )->method( 'get' )
178 ->willReturn( false );
179 // .. gets lock
180 $cache->expects( $this->once() )->method( 'lock' )
181 ->willReturn( true );
182
183 // Create config mock
184 $mock = $this->createConfigMock( [
185 'cache' => $cache,
186 ] );
187 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
188 ->willReturn(
189 self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
190
191 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
192 }
193
198 // Create cache mock
199 $cache = $this->getMockBuilder( HashBagOStuff::class )
200 ->setMethods( [ 'get', 'lock' ] )
201 ->getMock();
202 // .. misses cache
203 $cache->expects( $this->once() )->method( 'get' )
204 ->willReturn( false );
205 // .. gets lock
206 $cache->expects( $this->once() )->method( 'lock' )
207 ->willReturn( true );
208
209 // Create config mock
210 $mock = $this->createConfigMock( [
211 'cache' => $cache,
212 ] );
213 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
214 ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake error', ] ) );
215
216 $this->setExpectedException( ConfigException::class );
217 $mock->get( 'key' );
218 }
219
224 // Create cache mock
225 $cache = $this->getMockBuilder( HashBagOStuff::class )
226 ->setMethods( [ 'get', 'lock' ] )
227 ->getMock();
228 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
229 ->will( $this->onConsecutiveCalls(
230 // .. misses cache first time
231 false,
232 // .. hits cache on retry
233 [
234 'config' => [ 'known' => 'from-cache' ],
235 'expires' => INF,
236 'modifiedIndex' => 123
237 ]
238 ) );
239 // .. misses lock
240 $cache->expects( $this->once() )->method( 'lock' )
241 ->willReturn( false );
242
243 // Create config mock
244 $mock = $this->createConfigMock( [
245 'cache' => $cache,
246 ] );
247 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
248
249 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
250 }
251
255 public function testLoadCacheHit() {
256 // Create cache mock
257 $cache = $this->getMockBuilder( HashBagOStuff::class )
258 ->setMethods( [ 'get', 'lock' ] )
259 ->getMock();
260 $cache->expects( $this->once() )->method( 'get' )
261 // .. hits cache
262 ->willReturn( [
263 'config' => [ 'known' => 'from-cache' ],
264 'expires' => INF,
265 'modifiedIndex' => 0,
266 ] );
267 $cache->expects( $this->never() )->method( 'lock' );
268
269 // Create config mock
270 $mock = $this->createConfigMock( [
271 'cache' => $cache,
272 ] );
273 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
274
275 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
276 }
277
281 public function testLoadProcessCacheHit() {
282 // Create cache mock
283 $cache = $this->getMockBuilder( HashBagOStuff::class )
284 ->setMethods( [ 'get', 'lock' ] )
285 ->getMock();
286 $cache->expects( $this->once() )->method( 'get' )
287 // .. hits cache
288 ->willReturn( [
289 'config' => [ 'known' => 'from-cache' ],
290 'expires' => INF,
291 'modifiedIndex' => 0,
292 ] );
293 $cache->expects( $this->never() )->method( 'lock' );
294
295 // Create config mock
296 $mock = $this->createConfigMock( [
297 'cache' => $cache,
298 ] );
299 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
300
301 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
302 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
303 }
304
309 // Create cache mock
310 $cache = $this->getMockBuilder( HashBagOStuff::class )
311 ->setMethods( [ 'get', 'lock' ] )
312 ->getMock();
313 $cache->expects( $this->once() )->method( 'get' )->willReturn(
314 // .. stale cache
315 [
316 'config' => [ 'known' => 'from-cache-expired' ],
317 'expires' => -INF,
318 'modifiedIndex' => 0,
319 ]
320 );
321 // .. gets lock
322 $cache->expects( $this->once() )->method( 'lock' )
323 ->willReturn( true );
324
325 // Create config mock
326 $mock = $this->createConfigMock( [
327 'cache' => $cache,
328 ] );
329 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
330 ->willReturn( self::createEtcdResponse( [ 'config' => [ 'known' => 'from-fetch' ] ] ) );
331
332 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
333 }
334
339 // Create cache mock
340 $cache = $this->getMockBuilder( HashBagOStuff::class )
341 ->setMethods( [ 'get', 'lock' ] )
342 ->getMock();
343 $cache->expects( $this->once() )->method( 'get' )->willReturn(
344 // .. stale cache
345 [
346 'config' => [ 'known' => 'from-cache-expired' ],
347 'expires' => -INF,
348 'modifiedIndex' => 0,
349 ]
350 );
351 // .. gets lock
352 $cache->expects( $this->once() )->method( 'lock' )
353 ->willReturn( true );
354
355 // Create config mock
356 $mock = $this->createConfigMock( [
357 'cache' => $cache,
358 ] );
359 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
360 ->willReturn( self::createEtcdResponse( [ 'error' => 'Fake failure', 'retry' => true ] ) );
361
362 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
363 }
364
368 public function testLoadCacheExpiredNoLock() {
369 // Create cache mock
370 $cache = $this->getMockBuilder( HashBagOStuff::class )
371 ->setMethods( [ 'get', 'lock' ] )
372 ->getMock();
373 $cache->expects( $this->once() )->method( 'get' )
374 // .. hits cache (expired value)
375 ->willReturn( [
376 'config' => [ 'known' => 'from-cache-expired' ],
377 'expires' => -INF,
378 'modifiedIndex' => 0,
379 ] );
380 // .. misses lock
381 $cache->expects( $this->once() )->method( 'lock' )
382 ->willReturn( false );
383
384 // Create config mock
385 $mock = $this->createConfigMock( [
386 'cache' => $cache,
387 ] );
388 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
389
390 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
391 }
392
393 public static function provideFetchFromServer() {
394 return [
395 '200 OK - Success' => [
396 'http' => [
397 'code' => 200,
398 'reason' => 'OK',
399 'headers' => [],
400 'body' => json_encode( [ 'node' => [ 'nodes' => [
401 [
402 'key' => '/example/foo',
403 'value' => json_encode( [ 'val' => true ] ),
404 'modifiedIndex' => 123
405 ],
406 ] ] ] ),
407 'error' => '',
408 ],
409 'expect' => self::createEtcdResponse( [
410 'config' => [ 'foo' => true ], // data
411 'modifiedIndex' => 123
412 ] ),
413 ],
414 '200 OK - Empty dir' => [
415 'http' => [
416 'code' => 200,
417 'reason' => 'OK',
418 'headers' => [],
419 'body' => json_encode( [ 'node' => [ 'nodes' => [
420 [
421 'key' => '/example/foo',
422 'value' => json_encode( [ 'val' => true ] ),
423 'modifiedIndex' => 123
424 ],
425 [
426 'key' => '/example/sub',
427 'dir' => true,
428 'modifiedIndex' => 234,
429 'nodes' => [],
430 ],
431 [
432 'key' => '/example/bar',
433 'value' => json_encode( [ 'val' => false ] ),
434 'modifiedIndex' => 125
435 ],
436 ] ] ] ),
437 'error' => '',
438 ],
439 'expect' => self::createEtcdResponse( [
440 'config' => [ 'foo' => true, 'bar' => false ], // data
441 'modifiedIndex' => 125 // largest modified index
442 ] ),
443 ],
444 '200 OK - Recursive' => [
445 'http' => [
446 'code' => 200,
447 'reason' => 'OK',
448 'headers' => [],
449 'body' => json_encode( [ 'node' => [ 'nodes' => [
450 [
451 'key' => '/example/a',
452 'dir' => true,
453 'modifiedIndex' => 124,
454 'nodes' => [
455 [
456 'key' => 'b',
457 'value' => json_encode( [ 'val' => true ] ),
458 'modifiedIndex' => 123,
459
460 ],
461 [
462 'key' => 'c',
463 'value' => json_encode( [ 'val' => false ] ),
464 'modifiedIndex' => 123,
465 ],
466 ],
467 ],
468 ] ] ] ),
469 'error' => '',
470 ],
471 'expect' => self::createEtcdResponse( [
472 'config' => [ 'a/b' => true, 'a/c' => false ], // data
473 'modifiedIndex' => 123 // largest modified index
474 ] ),
475 ],
476 '200 OK - Missing nodes at second level' => [
477 'http' => [
478 'code' => 200,
479 'reason' => 'OK',
480 'headers' => [],
481 'body' => json_encode( [ 'node' => [ 'nodes' => [
482 [
483 'key' => '/example/a',
484 'dir' => true,
485 'modifiedIndex' => 0,
486 ],
487 ] ] ] ),
488 'error' => '',
489 ],
490 'expect' => self::createEtcdResponse( [
491 'error' => "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
492 ] ),
493 ],
494 '200 OK - Directory with non-array "nodes" key' => [
495 'http' => [
496 'code' => 200,
497 'reason' => 'OK',
498 'headers' => [],
499 'body' => json_encode( [ 'node' => [ 'nodes' => [
500 [
501 'key' => '/example/a',
502 'dir' => true,
503 'nodes' => 'not an array'
504 ],
505 ] ] ] ),
506 'error' => '',
507 ],
508 'expect' => self::createEtcdResponse( [
509 'error' => "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
510 ] ),
511 ],
512 '200 OK - Correctly encoded garbage response' => [
513 'http' => [
514 'code' => 200,
515 'reason' => 'OK',
516 'headers' => [],
517 'body' => json_encode( [ 'foo' => 'bar' ] ),
518 'error' => '',
519 ],
520 'expect' => self::createEtcdResponse( [
521 'error' => "Unexpected JSON response: Missing or invalid node at top level.",
522 ] ),
523 ],
524 '200 OK - Bad value' => [
525 'http' => [
526 'code' => 200,
527 'reason' => 'OK',
528 'headers' => [],
529 'body' => json_encode( [ 'node' => [ 'nodes' => [
530 [
531 'key' => '/example/foo',
532 'value' => ';"broken{value',
533 'modifiedIndex' => 123,
534 ]
535 ] ] ] ),
536 'error' => '',
537 ],
538 'expect' => self::createEtcdResponse( [
539 'error' => "Failed to parse value for 'foo'.",
540 ] ),
541 ],
542 '200 OK - Empty node list' => [
543 'http' => [
544 'code' => 200,
545 'reason' => 'OK',
546 'headers' => [],
547 'body' => '{"node":{"nodes":[], "modifiedIndex": 12 }}',
548 'error' => '',
549 ],
550 'expect' => self::createEtcdResponse( [
551 'config' => [], // data
552 ] ),
553 ],
554 '200 OK - Invalid JSON' => [
555 'http' => [
556 'code' => 200,
557 'reason' => 'OK',
558 'headers' => [ 'content-length' => 0 ],
559 'body' => '',
560 'error' => '(curl error: no status set)',
561 ],
562 'expect' => self::createEtcdResponse( [
563 'error' => "Error unserializing JSON response.",
564 ] ),
565 ],
566 '404 Not Found' => [
567 'http' => [
568 'code' => 404,
569 'reason' => 'Not Found',
570 'headers' => [ 'content-length' => 0 ],
571 'body' => '',
572 'error' => '',
573 ],
574 'expect' => self::createEtcdResponse( [
575 'error' => 'HTTP 404 (Not Found)',
576 ] ),
577 ],
578 '400 Bad Request - custom error' => [
579 'http' => [
580 'code' => 400,
581 'reason' => 'Bad Request',
582 'headers' => [ 'content-length' => 0 ],
583 'body' => '',
584 'error' => 'No good reason',
585 ],
586 'expect' => self::createEtcdResponse( [
587 'error' => 'No good reason',
588 'retry' => true, // retry
589 ] ),
590 ],
591 ];
592 }
593
602 public function testFetchFromServer( array $httpResponse, array $expected ) {
603 $http = $this->getMockBuilder( MultiHttpClient::class )
604 ->disableOriginalConstructor()
605 ->getMock();
606 $http->expects( $this->once() )->method( 'run' )
607 ->willReturn( array_values( $httpResponse ) );
608
609 $conf = $this->getMockBuilder( EtcdConfig::class )
610 ->disableOriginalConstructor()
611 ->getMock();
612 // Access for protected member and method
613 $conf = TestingAccessWrapper::newFromObject( $conf );
614 $conf->http = $http;
615
616 $this->assertSame(
617 $expected,
618 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )
619 );
620 }
621}
testFetchFromServer(array $httpResponse, array $expected)
EtcdConfig::fetchAllFromEtcdServer EtcdConfig::unserialize EtcdConfig::parseResponse EtcdConfig::pars...
createConfigMock(array $options=[])
testLoadProcessCacheHit()
EtcdConfig::load.
testLoadCacheMissBackendError()
EtcdConfig::load.
testHasKnown()
EtcdConfig::has.
testConstructCacheObj()
EtcdConfig::__construct.
static provideFetchFromServer()
testLoadCacheHit()
EtcdConfig::load.
testConstructCacheSpec()
EtcdConfig::__construct.
createSimpleConfigMock(array $config, $index=0)
testLoadCacheExpiredLockFetchSucceeded()
EtcdConfig::load.
static createEtcdResponse(array $response)
testLoadCacheExpiredLockFetchFails()
EtcdConfig::load.
testGetUnknown()
EtcdConfig::get.
testGetKnown()
EtcdConfig::__construct EtcdConfig::get.
testLoadCacheMiss()
Test matrix.
testLoadCacheMissWithoutLock()
EtcdConfig::load.
testLoadCacheExpiredNoLock()
EtcdConfig::load.
testHasUnknown()
EtcdConfig::has.
testGetModifiedIndex()
EtcdConfig::getModifiedIndex.
null means default in associative array with keys and values unescaped Should be merged with default with a value of false meaning to suppress the attribute in associative array with keys and values unescaped & $options
Definition hooks.txt:2001
this hook is for auditing only $response
Definition hooks.txt:783
$cache
Definition mcc.php:33