3 use Wikimedia\TestingAccessWrapper
;
5 class EtcConfigTest
extends PHPUnit_Framework_TestCase
{
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 function createSimpleConfigMock( array $config ) {
19 $mock = $this->createConfigMock();
20 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
30 * @covers EtcdConfig::has
32 public function testHasKnown() {
33 $config = $this->createSimpleConfigMock( [
36 $this->assertSame( true, $config->has( 'known' ) );
40 * @covers EtcdConfig::__construct
41 * @covers EtcdConfig::get
43 public function testGetKnown() {
44 $config = $this->createSimpleConfigMock( [
47 $this->assertSame( 'value', $config->get( 'known' ) );
51 * @covers EtcdConfig::has
53 public function testHasUnknown() {
54 $config = $this->createSimpleConfigMock( [
57 $this->assertSame( false, $config->has( 'unknown' ) );
61 * @covers EtcdConfig::get
63 public function testGetUnknown() {
64 $config = $this->createSimpleConfigMock( [
67 $this->setExpectedException( ConfigException
::class );
68 $config->get( 'unknown' );
72 * @covers EtcdConfig::__construct
74 public function testConstructCacheObj() {
75 $cache = $this->getMockBuilder( HashBagOStuff
::class )
76 ->setMethods( [ 'get' ] )
78 $cache->expects( $this->once() )->method( 'get' )
80 'config' => [ 'known' => 'from-cache' ],
83 $config = $this->createConfigMock( [ 'cache' => $cache ] );
85 $this->assertSame( 'from-cache', $config->get( 'known' ) );
89 * @covers EtcdConfig::__construct
91 public function testConstructCacheSpec() {
92 $config = $this->createConfigMock( [ 'cache' => [
93 'class' => HashBagOStuff
::class
95 $config->expects( $this->once() )->method( 'fetchAllFromEtcd' )
97 [ 'known' => 'from-fetch' ],
102 $this->assertSame( 'from-fetch', $config->get( 'known' ) );
109 * Result: Fetched value
110 * > cache miss | gets lock | backend succeeds
112 * - [x] Cache miss with backend error
113 * Result: ConfigException
114 * > cache miss | gets lock | backend error (no retry)
116 * - [x] Cache hit after retry
117 * Result: Cached value (populated by process holding lock)
118 * > cache miss | no lock | cache retry
121 * Result: Cached value
124 * - [x] Process cache hit
125 * Result: Cached value
126 * > process cache hit
128 * - [x] Cache expired
129 * Result: Fetched value
130 * > cache expired | gets lock | backend succeeds
132 * - [x] Cache expired with backend failure
133 * Result: Cached value (stale)
134 * > cache expired | gets lock | backend fails (allows retry)
136 * - [x] Cache expired and no lock
137 * Result: Cached value (stale)
138 * > cache expired | no lock
140 * Other notable scenarios:
142 * - [ ] Cache miss with backend retry
143 * Result: Fetched value
144 * > cache expired | gets lock | backend failure (allows retry)
148 * @covers EtcdConfig::load
150 public function testLoadCacheMiss() {
152 $cache = $this->getMockBuilder( HashBagOStuff
::class )
153 ->setMethods( [ 'get', 'lock' ] )
156 $cache->expects( $this->once() )->method( 'get' )
157 ->willReturn( false );
159 $cache->expects( $this->once() )->method( 'lock' )
160 ->willReturn( true );
162 // Create config mock
163 $mock = $this->createConfigMock( [
166 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
167 ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
169 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
173 * @covers EtcdConfig::load
175 public function testLoadCacheMissBackendError() {
177 $cache = $this->getMockBuilder( HashBagOStuff
::class )
178 ->setMethods( [ 'get', 'lock' ] )
181 $cache->expects( $this->once() )->method( 'get' )
182 ->willReturn( false );
184 $cache->expects( $this->once() )->method( 'lock' )
185 ->willReturn( true );
187 // Create config mock
188 $mock = $this->createConfigMock( [
191 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
192 ->willReturn( [ null, 'Fake error', false ] );
194 $this->setExpectedException( ConfigException
::class );
199 * @covers EtcdConfig::load
201 public function testLoadCacheMissWithoutLock() {
203 $cache = $this->getMockBuilder( HashBagOStuff
::class )
204 ->setMethods( [ 'get', 'lock' ] )
206 $cache->expects( $this->exactly( 2 ) )->method( 'get' )
207 ->will( $this->onConsecutiveCalls(
208 // .. misses cache first time
210 // .. hits cache on retry
212 'config' => [ 'known' => 'from-cache' ],
217 $cache->expects( $this->once() )->method( 'lock' )
218 ->willReturn( false );
220 // Create config mock
221 $mock = $this->createConfigMock( [
224 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
226 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
230 * @covers EtcdConfig::load
232 public function testLoadCacheHit() {
234 $cache = $this->getMockBuilder( HashBagOStuff
::class )
235 ->setMethods( [ 'get', 'lock' ] )
237 $cache->expects( $this->once() )->method( 'get' )
240 'config' => [ 'known' => 'from-cache' ],
243 $cache->expects( $this->never() )->method( 'lock' );
245 // Create config mock
246 $mock = $this->createConfigMock( [
249 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
251 $this->assertSame( 'from-cache', $mock->get( 'known' ) );
255 * @covers EtcdConfig::load
257 public function testLoadProcessCacheHit() {
259 $cache = $this->getMockBuilder( HashBagOStuff
::class )
260 ->setMethods( [ 'get', 'lock' ] )
262 $cache->expects( $this->once() )->method( 'get' )
265 'config' => [ 'known' => 'from-cache' ],
268 $cache->expects( $this->never() )->method( 'lock' );
270 // Create config mock
271 $mock = $this->createConfigMock( [
274 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
276 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Cache hit' );
277 $this->assertSame( 'from-cache', $mock->get( 'known' ), 'Process cache hit' );
281 * @covers EtcdConfig::load
283 public function testLoadCacheExpiredLockFetchSucceeded() {
285 $cache = $this->getMockBuilder( HashBagOStuff
::class )
286 ->setMethods( [ 'get', 'lock' ] )
288 $cache->expects( $this->once() )->method( 'get' )->willReturn(
291 'config' => [ 'known' => 'from-cache-expired' ],
296 $cache->expects( $this->once() )->method( 'lock' )
297 ->willReturn( true );
299 // Create config mock
300 $mock = $this->createConfigMock( [
303 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
304 ->willReturn( [ [ 'known' => 'from-fetch' ], null, false ] );
306 $this->assertSame( 'from-fetch', $mock->get( 'known' ) );
310 * @covers EtcdConfig::load
312 public function testLoadCacheExpiredLockFetchFails() {
314 $cache = $this->getMockBuilder( HashBagOStuff
::class )
315 ->setMethods( [ 'get', 'lock' ] )
317 $cache->expects( $this->once() )->method( 'get' )->willReturn(
320 'config' => [ 'known' => 'from-cache-expired' ],
325 $cache->expects( $this->once() )->method( 'lock' )
326 ->willReturn( true );
328 // Create config mock
329 $mock = $this->createConfigMock( [
332 $mock->expects( $this->once() )->method( 'fetchAllFromEtcd' )
333 ->willReturn( [ null, 'Fake failure', true ] );
335 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
339 * @covers EtcdConfig::load
341 public function testLoadCacheExpiredNoLock() {
343 $cache = $this->getMockBuilder( HashBagOStuff
::class )
344 ->setMethods( [ 'get', 'lock' ] )
346 $cache->expects( $this->once() )->method( 'get' )
347 // .. hits cache (expired value)
349 'config' => [ 'known' => 'from-cache-expired' ],
353 $cache->expects( $this->once() )->method( 'lock' )
354 ->willReturn( false );
356 // Create config mock
357 $mock = $this->createConfigMock( [
360 $mock->expects( $this->never() )->method( 'fetchAllFromEtcd' );
362 $this->assertSame( 'from-cache-expired', $mock->get( 'known' ) );
365 public static function provideFetchFromServer() {
367 '200 OK - Success' => [
372 'body' => json_encode( [ 'node' => [ 'nodes' => [
374 'key' => '/example/foo',
375 'value' => json_encode( [ 'val' => true ] )
381 [ 'foo' => true ], // data
386 '200 OK - Empty dir' => [
391 'body' => json_encode( [ 'node' => [ 'nodes' => [
393 'key' => '/example/foo',
394 'value' => json_encode( [ 'val' => true ] )
397 'key' => '/example/sub',
402 'key' => '/example/bar',
403 'value' => json_encode( [ 'val' => false ] )
409 [ 'foo' => true, 'bar' => false ], // data
414 '200 OK - Recursive' => [
419 'body' => json_encode( [ 'node' => [ 'nodes' => [
421 'key' => '/example/a',
426 'value' => json_encode( [ 'val' => true ] ),
430 'value' => json_encode( [ 'val' => false ] ),
438 [ 'a/b' => true, 'a/c' => false ], // data
443 '200 OK - Missing nodes at second level' => [
448 'body' => json_encode( [ 'node' => [ 'nodes' => [
450 'key' => '/example/a',
458 "Unexpected JSON response in dir 'a'; missing 'nodes' list.",
462 '200 OK - Correctly encoded garbage response' => [
467 'body' => json_encode( [ 'foo' => 'bar' ] ),
472 "Unexpected JSON response: Missing or invalid node at top level.",
476 '200 OK - Bad value' => [
481 'body' => json_encode( [ 'node' => [ 'nodes' => [
483 'key' => '/example/foo',
484 'value' => ';"broken{value'
491 "Failed to parse value for 'foo'.",
495 '200 OK - Empty node list' => [
500 'body' => '{"node":{"nodes":[]}}',
509 '200 OK - Invalid JSON' => [
513 'headers' => [ 'content-length' => 0 ],
515 'error' => '(curl error: no status set)',
519 "Error unserializing JSON response.",
526 'reason' => 'Not Found',
527 'headers' => [ 'content-length' => 0 ],
533 'HTTP 404 (Not Found)',
537 '400 Bad Request - custom error' => [
540 'reason' => 'Bad Request',
541 'headers' => [ 'content-length' => 0 ],
543 'error' => 'No good reason',
555 * @covers EtcdConfig::fetchAllFromEtcdServer
556 * @covers EtcdConfig::unserialize
557 * @covers EtcdConfig::parseResponse
558 * @covers EtcdConfig::parseDirectory
559 * @covers EtcdConfigParseError
560 * @dataProvider provideFetchFromServer
562 public function testFetchFromServer( array $httpResponse, array $expected ) {
563 $http = $this->getMockBuilder( MultiHttpClient
::class )
564 ->disableOriginalConstructor()
566 $http->expects( $this->once() )->method( 'run' )
567 ->willReturn( array_values( $httpResponse ) );
569 $conf = $this->getMockBuilder( EtcdConfig
::class )
570 ->disableOriginalConstructor()
572 // Access for protected member and method
573 $conf = TestingAccessWrapper
::newFromObject( $conf );
578 $conf->fetchAllFromEtcdServer( 'etcd-tcp.example.net' )