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 function createSimpleConfigMock( array $config ) {
21 $mock = $this->createConfigMock();
22 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
32 * @covers EtcdConfig::has
34 public function testHasKnown() {
35 $config = $this->createSimpleConfigMock( [
38 $this->assertSame( true, $config->has( 'known' ) );
42 * @covers EtcdConfig::__construct
43 * @covers EtcdConfig::get
45 public function testGetKnown() {
46 $config = $this->createSimpleConfigMock( [
49 $this->assertSame( 'value', $config->get( 'known' ) );
53 * @covers EtcdConfig::has
55 public function testHasUnknown() {
56 $config = $this->createSimpleConfigMock( [
59 $this->assertSame( false, $config->has( 'unknown' ) );
63 * @covers EtcdConfig::get
65 public function testGetUnknown() {
66 $config = $this->createSimpleConfigMock( [
69 $this->setExpectedException( ConfigException
::class );
70 $config->get( 'unknown' );
74 * @covers EtcdConfig::__construct
76 public function testConstructCacheObj() {
77 $cache = $this->getMockBuilder( HashBagOStuff
::class )
78 ->setMethods( [ 'get' ] )
80 $cache->expects( $this->once() )->method( 'get' )
82 'config' => [ 'known' => 'from-cache' ],
85 $config = $this->createConfigMock( [ 'cache' => $cache ] );
87 $this->assertSame( 'from-cache', $config->get( 'known' ) );
91 * @covers EtcdConfig::__construct
93 public function testConstructCacheSpec() {
94 $config = $this->createConfigMock( [ 'cache' => [
95 'class' => HashBagOStuff
::class
97 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
99 [ 'known' => 'from-fetch' ],
104 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
111 * Result: Fetched value
112 * > cache miss | gets lock | backend succeeds
114 * - [x] Cache miss with backend error
115 * Result: ConfigException
116 * > cache miss | gets lock | backend error (no retry)
118 * - [x] Cache hit after retry
119 * Result: Cached value (populated by process holding lock)
120 * > cache miss | no lock | cache retry
123 * Result: Cached value
126 * - [x] Process cache hit
127 * Result: Cached value
128 * > process cache hit
130 * - [x] Cache expired
131 * Result: Fetched value
132 * > cache expired | gets lock | backend succeeds
134 * - [x] Cache expired with backend failure
135 * Result: Cached value (stale)
136 * > cache expired | gets lock | backend fails (allows retry)
138 * - [x] Cache expired and no lock
139 * Result: Cached value (stale)
140 * > cache expired | no lock
142 * Other notable scenarios:
144 * - [ ] Cache miss with backend retry
145 * Result: Fetched value
146 * > cache expired | gets lock | backend failure (allows retry)
150 * @covers EtcdConfig::load
152 public function testLoadCacheMiss() {
154 $cache = $this->getMockBuilder( HashBagOStuff
::class )
155 ->setMethods( [ 'get', 'lock' ] )
158 $cache->expects( $this->once() )->method( 'get' )
159 ->willReturn( false );
161 $cache->expects( $this->once() )->method( 'lock' )
162 ->willReturn( true );
164 // Create config mock
165 $mock = $this->createConfigMock( [
168 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
169 ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
171 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
175 * @covers EtcdConfig::load
177 public function testLoadCacheMissBackendError() {
179 $cache = $this->getMockBuilder( HashBagOStuff
::class )
180 ->setMethods( [ 'get', 'lock' ] )
183 $cache->expects( $this->once() )->method( 'get' )
184 ->willReturn( false );
186 $cache->expects( $this->once() )->method( 'lock' )
187 ->willReturn( true );
189 // Create config mock
190 $mock = $this->createConfigMock( [
193 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
194 ->willReturn( [ null, 'Fake error', false ] );
196 $this->setExpectedException( ConfigException
::class );
201 * @covers EtcdConfig::load
203 public function testLoadCacheMissWithoutLock() {
205 $cache = $this->getMockBuilder( HashBagOStuff
::class )
206 ->setMethods( [ 'get', 'lock' ] )
208 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
209 ->will( $this->onConsecutiveCalls(
210 // .. misses cache first time
212 // .. hits cache on retry
214 'config' => [ 'known' => 'from-cache' ],
219 $cache->expects( $this->once() )->method( 'lock' )
220 ->willReturn( false );
222 // Create config mock
223 $mock = $this->createConfigMock( [
226 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
228 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
232 * @covers EtcdConfig::load
234 public function testLoadCacheHit() {
236 $cache = $this->getMockBuilder( HashBagOStuff
::class )
237 ->setMethods( [ 'get', 'lock' ] )
239 $cache->expects( $this->once() )->method( 'get' )
242 'config' => [ 'known' => 'from-cache' ],
245 $cache->expects( $this->never() )->method( 'lock' );
247 // Create config mock
248 $mock = $this->createConfigMock( [
251 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
253 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
257 * @covers EtcdConfig::load
259 public function testLoadProcessCacheHit() {
261 $cache = $this->getMockBuilder( HashBagOStuff
::class )
262 ->setMethods( [ 'get', 'lock' ] )
264 $cache->expects( $this->once() )->method( 'get' )
267 'config' => [ 'known' => 'from-cache' ],
270 $cache->expects( $this->never() )->method( 'lock' );
272 // Create config mock
273 $mock = $this->createConfigMock( [
276 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
278 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
279 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
283 * @covers EtcdConfig::load
285 public function testLoadCacheExpiredLockFetchSucceeded() {
287 $cache = $this->getMockBuilder( HashBagOStuff
::class )
288 ->setMethods( [ 'get', 'lock' ] )
290 $cache->expects( $this->once() )->method( 'get' )->willReturn(
293 'config' => [ 'known' => 'from-cache-expired' ],
298 $cache->expects( $this->once() )->method( 'lock' )
299 ->willReturn( true );
301 // Create config mock
302 $mock = $this->createConfigMock( [
305 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
306 ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
308 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
312 * @covers EtcdConfig::load
314 public function testLoadCacheExpiredLockFetchFails() {
316 $cache = $this->getMockBuilder( HashBagOStuff
::class )
317 ->setMethods( [ 'get', 'lock' ] )
319 $cache->expects( $this->once() )->method( 'get' )->willReturn(
322 'config' => [ 'known' => 'from-cache-expired' ],
327 $cache->expects( $this->once() )->method( 'lock' )
328 ->willReturn( true );
330 // Create config mock
331 $mock = $this->createConfigMock( [
334 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
335 ->willReturn( [ null, 'Fake failure', true ] );
337 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
341 * @covers EtcdConfig::load
343 public function testLoadCacheExpiredNoLock() {
345 $cache = $this->getMockBuilder( HashBagOStuff
::class )
346 ->setMethods( [ 'get', 'lock' ] )
348 $cache->expects( $this->once() )->method( 'get' )
349 // .. hits cache (expired value)
351 'config' => [ 'known' => 'from-cache-expired' ],
355 $cache->expects( $this->once() )->method( 'lock' )
356 ->willReturn( false );
358 // Create config mock
359 $mock = $this->createConfigMock( [
362 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
364 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
367 public static function provideFetchFromServer() {
369 '200 OK - Success' => [
374 'body' => json_encode( [ 'node' => [ 'nodes' => [
376 'key' => '/example/foo',
377 'value' => json_encode( [ 'val' => true ] )
383 [ 'foo' => true ], // data
388 '200 OK - Empty dir' => [
393 'body' => json_encode( [ 'node' => [ 'nodes' => [
395 'key' => '/example/foo',
396 'value' => json_encode( [ 'val' => true ] )
399 'key' => '/example/sub',
404 'key' => '/example/bar',
405 'value' => json_encode( [ 'val' => false ] )
411 [ 'foo' => true, 'bar' => false ], // data
416 '200 OK - Recursive' => [
421 'body' => json_encode( [ 'node' => [ 'nodes' => [
423 'key' => '/example/a',
428 'value' => json_encode( [ 'val' => true ] ),
432 'value' => json_encode( [ 'val' => false ] ),
440 [ 'a/b' => true, 'a/c' => false ], // data
445 '200 OK - Missing nodes at second level' => [
450 'body' => json_encode( [ 'node' => [ 'nodes' => [
452 'key' => '/example/a',
460 "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
464 '200 OK - Directory with non-array "nodes" key' => [
469 'body' => json_encode( [ 'node' => [ 'nodes' => [
471 'key' => '/example/a',
473 'nodes' => 'not an array'
480 "Unexpected JSON response in dir 'a'; 'nodes' is not an array.",
484 '200 OK - Correctly encoded garbage response' => [
489 'body' => json_encode( [ 'foo' => 'bar' ] ),
494 "Unexpected JSON response: Missing or invalid node at top level.",
498 '200 OK - Bad value' => [
503 'body' => json_encode( [ 'node' => [ 'nodes' => [
505 'key' => '/example/foo',
506 'value' => ';"broken{value'
513 "Failed to parse value for 'foo'.",
517 '200 OK - Empty node list' => [
522 'body' => '{"node":{"nodes":[]}}',
531 '200 OK - Invalid JSON' => [
535 'headers' => [ 'content-length' => 0 ],
537 'error' => '(curl error: no status set)',
541 "Error unserializing JSON response.",
548 'reason' => 'Not Found',
549 'headers' => [ 'content-length' => 0 ],
555 'HTTP 404 (Not Found)',
559 '400 Bad Request - custom error' => [
562 'reason' => 'Bad Request',
563 'headers' => [ 'content-length' => 0 ],
565 'error' => 'No good reason',
577 * @covers EtcdConfig::fetchAllFromEtcdServer
578 * @covers EtcdConfig::unserialize
579 * @covers EtcdConfig::parseResponse
580 * @covers EtcdConfig::parseDirectory
581 * @covers EtcdConfigParseError
582 * @dataProvider provideFetchFromServer
584 public function testFetchFromServer( array $httpResponse, array $expected ) {
585 $http = $this->getMockBuilder( MultiHttpClient
::class )
586 ->disableOriginalConstructor()
588 $http->expects( $this->once() )->method( 'run' )
589 ->willReturn( array_values( $httpResponse ) );
591 $conf = $this->getMockBuilder( EtcdConfig
::class )
592 ->disableOriginalConstructor()
594 // Access for protected member and method
595 $conf = TestingAccessWrapper
::newFromObject( $conf );
600 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )