wiki.techinc.nl/tests/phpunit/unit/includes/Settings/Source/EtcdSourceTest.php
Dan Duvall 4f4c831111 Support etcd as a source for SettingsLoader
Added a EtcdSource to handle loading of settings from etcd. The
implementation is based on EtcdConfig but is much simpler due to
the reliance on the existing source caching layer.

GuzzleHttp\Client is used over MultiHttpClient as the latter depends on
MediaWikiServices and therefore should not be used during early
initialization.

A naive cache key is based on the etcd request URL, effectively
representing the etcd API version and settings directory, and uses
either the DNS SRV entry as the host name or the host name itself if
discovery is disabled.

The cache TTL is set to 10 seconds. The combination of this low TTL and
the naive key should replicate the current caching pattern of
EtcdConfig. Stale results are allowed for failover in case of temporary
unavailability.

At this time, the expiry weight was not changed from the suggested 1.0.
However, verification of this as a suitable early expiration coefficient
should be performed in a production like environment.

Bug: T296771
Change-Id: I782f4ee567a986fd23df1a84aec629e648a29066
2022-06-21 17:17:32 +00:00

250 lines
5.4 KiB
PHP

<?php
namespace MediaWiki\Tests\Unit\Settings\Source;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\EtcdSource;
use PHPUnit\Framework\TestCase;
/**
* @covers \MediaWiki\Settings\Source\EtcdSource
*/
class EtcdSourceTest extends TestCase {
public function testGetExpiryTtl() {
$source = new EtcdSource();
$this->assertSame( 10, $source->getExpiryTtl() );
}
public function testGetExpiryWeight() {
$source = new EtcdSource();
$this->assertSame( 1.0, $source->getExpiryWeight() );
}
public function testGetHashKey() {
$source = new EtcdSource( [ 'host' => 'an.example' ] );
$this->assertSame(
'https://_etcd-client-ssl._tcp.an.example/v2/keys/mediawiki/?recursive=true',
$source->getHashKey()
);
}
public function testGetHashKeyNoDiscovery() {
$source = new EtcdSource( [
'host' => 'an.example',
'discover' => false,
] );
$this->assertSame(
'https://an.example:2379/v2/keys/mediawiki/?recursive=true',
$source->getHashKey()
);
}
public function testToString() {
$source = new EtcdSource( [
'host' => 'an.example',
'discover' => false,
] );
$this->assertSame(
'https://an.example:2379/v2/keys/mediawiki/?recursive=true',
(string)$source
);
}
public function testLoad() {
$mapper = $this->mockCallable();
$client = $this->mockClientWithResponses( [ [ 200, 'valid.json' ] ] );
$resolver = $this->mockCallable();
$settings = [
'Fish/One' => 'fish',
'Fish/Two' => 'fish',
'MoreFish/Red' => 'fish',
'MoreFish/Blue' => 'fish',
];
$source = new EtcdSource( [], $mapper, $client, $resolver );
$mapper->expects( $this->once() )
->method( '__invoke' )
->with( $settings )
->willReturn( $settings );
$resolver->expects( $this->once() )
->method( '__invoke' )
->willReturn( [
[ 'an.example', 4001 ]
] );
$this->assertSame(
[
'Fish/One' => 'fish',
'Fish/Two' => 'fish',
'MoreFish/Red' => 'fish',
'MoreFish/Blue' => 'fish',
],
$source->load()
);
}
/**
* @dataProvider serverFailures
*/
public function testLoadAllServersFailed( GuzzleException $exception ) {
$client = $this->mockClientWithResponses( [
$exception,
$exception,
] );
$resolver = $this->mockCallable();
$source = new EtcdSource( [], null, $client, $resolver );
$resolver->expects( $this->once() )
->method( '__invoke' )
->willReturn( [
[ 'bad.example', 123 ],
[ 'bad.example', 321 ],
] );
$this->expectException( SettingsBuilderException::class );
$source->load();
}
/**
* @dataProvider serverFailures
*/
public function testLoadSomeServersFailed( GuzzleException $exception ) {
$client = $this->mockClientWithResponses( [
$exception,
[ 200, 'valid.json' ],
] );
$resolver = $this->mockCallable();
$source = new EtcdSource( [], null, $client, $resolver );
$resolver->expects( $this->once() )
->method( '__invoke' )
->willReturn( [
[ 'bad.example', 123 ],
[ 'ok.example', 321 ],
] );
$this->assertSame(
[
'Fish/One' => 'fish',
'Fish/Two' => 'fish',
'MoreFish/Red' => 'fish',
'MoreFish/Blue' => 'fish',
],
$source->load()
);
}
public function testLoadClientFailed() {
$client = $this->mockClientWithResponses( [
new ClientException(
'bad request',
new Request( 'GET', '/' ),
new Response( 400, [] )
)
] );
$resolver = $this->mockCallable();
$source = new EtcdSource( [], null, $client, $resolver );
$resolver->expects( $this->once() )
->method( '__invoke' )
->willReturn( [
[ 'ok.example', 123 ],
[ 'ok.example', 321 ],
] );
$this->expectException( SettingsBuilderException::class );
$source->load();
}
public function testLoadNotAnEtcdDirectory() {
$client = $this->mockClientWithResponses( [
[ 200, 'notadirectory.json' ],
] );
$resolver = $this->mockCallable();
$source = new EtcdSource( [], null, $client, $resolver );
$resolver->expects( $this->once() )
->method( '__invoke' )
->willReturn( [
[ 'an.example', 123 ],
] );
$this->expectException( SettingsBuilderException::class );
$source->load();
}
/**
* All possible server-side exceptions.
*
* @return array
*/
public function serverFailures(): array {
return [
[
new ConnectException(
'connection failure',
new Request( 'GET', '/foo' )
)
],
[
new ServerException(
'server side failure',
new Request( 'GET', '/foo' ),
new Response( 500, [], 'server error' )
),
],
];
}
/**
* @param array $responses
*
* @return Client
*/
private function mockClientWithResponses( array $responses ): Client {
return new Client( [
'handler' => HandlerStack::create(
new MockHandler( array_map( static function ( $response ) {
return is_array( $response )
? new Response(
$response[0],
[ 'content-type' => 'application/json' ],
file_get_contents( __DIR__ . '/fixtures/etcd/' . $response[1] )
)
: $response;
}, $responses ) )
),
] );
}
/**
* @return callable
*/
private function mockCallable() {
return $this
->getMockBuilder( __CLASS__ )
->addMethods( [ '__invoke' ] )
->getMock();
}
}