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