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