Merge pull request #464 from acelaya/feature/external-ip-geolocation-module

Moved IpGeolocation module to external library
This commit is contained in:
Alejandro Celaya 2019-08-12 20:12:29 +02:00 committed by GitHub
commit b3a4adeba4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1 additions and 1049 deletions

View file

@ -35,6 +35,7 @@
"predis/predis": "^1.1",
"shlinkio/shlink-common": "^1.0",
"shlinkio/shlink-installer": "^1.2.1",
"shlinkio/shlink-ip-geolocation": "^1.0",
"symfony/console": "^4.3",
"symfony/filesystem": "^4.3",
"symfony/lock": "^4.3",
@ -76,7 +77,6 @@
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\EventDispatcher\\": "module/EventDispatcher/src",
"Shlinkio\\Shlink\\IpGeolocation\\": "module/IpGeolocation/src/",
"Shlinkio\\Shlink\\PreviewGenerator\\": "module/PreviewGenerator/src/"
},
"files": [
@ -93,7 +93,6 @@
"module/Core/test-db"
],
"ShlinkioTest\\Shlink\\EventDispatcher\\": "module/EventDispatcher/test",
"ShlinkioTest\\Shlink\\IpGeolocation\\": "module/IpGeolocation/test",
"ShlinkioTest\\Shlink\\PreviewGenerator\\": "module/PreviewGenerator/test"
}
},

View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2019 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,30 +0,0 @@
# Shlink IP Address Geolocation module
Shlink module with tools to geolocate an IP address using different strategies.
Most of the elements it provides require a [PSR-11] container, and it's easy to integrate on [expressive] applications thanks to the `ConfigProvider` it includes.
## Install
Install this library using composer:
composer require shlinkio/shlink-ip-geolocation
> This library is also an expressive module which provides its own `ConfigProvider`. Add it to your configuration to get everything automatically set up.
## *TODO*
```php
<?php
declare(strict_types=1);
return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => sys_get_temp_dir(),
// 'download_from' => 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz',
],
];
```

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation;
use GeoIp2\Database\Reader;
use GuzzleHttp\Client as GuzzleClient;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
'factories' => [
Resolver\IpApiLocationResolver::class => ConfigAbstractFactory::class,
Resolver\GeoLite2LocationResolver::class => ConfigAbstractFactory::class,
Resolver\EmptyIpLocationResolver::class => InvokableFactory::class,
Resolver\ChainIpLocationResolver::class => ConfigAbstractFactory::class,
GeoLite2\GeoLite2Options::class => ConfigAbstractFactory::class,
GeoLite2\DbUpdater::class => ConfigAbstractFactory::class,
],
'aliases' => [
Resolver\IpLocationResolverInterface::class => Resolver\ChainIpLocationResolver::class,
],
],
ConfigAbstractFactory::class => [
Resolver\IpApiLocationResolver::class => [GuzzleClient::class],
Resolver\GeoLite2LocationResolver::class => [Reader::class],
Resolver\ChainIpLocationResolver::class => [
Resolver\GeoLite2LocationResolver::class,
Resolver\IpApiLocationResolver::class,
Resolver\EmptyIpLocationResolver::class,
],
GeoLite2\GeoLite2Options::class => ['config.geolite2'],
GeoLite2\DbUpdater::class => [
GuzzleClient::class,
Filesystem::class,
GeoLite2\GeoLite2Options::class,
],
],
];

View file

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation;
use GeoIp2\Database\Reader;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Proxy\LazyServiceFactory;
return [
'dependencies' => [
'factories' => [
Reader::class => ConfigAbstractFactory::class,
],
'delegators' => [
// The GeoLite2 db reader has to be lazy so that it does not try to load the DB file at app bootstrapping.
// By doing so, it would fail the first time shlink tries to download it.
Reader::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
Reader::class => Reader::class,
],
],
],
ConfigAbstractFactory::class => [
Reader::class => ['config.geolite2.db_location'],
],
];

View file

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation;
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
class ConfigProvider
{
public function __invoke(): array
{
return loadConfigFromGlob(__DIR__ . '/../config/{,*.}config.php');
}
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Exception;
use Throwable;
interface ExceptionInterface extends Throwable
{
}

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Exception;
use RuntimeException as SplRuntimeException;
class RuntimeException extends SplRuntimeException implements ExceptionInterface
{
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Exception;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Throwable;
use function sprintf;
class WrongIpException extends RuntimeException implements ExceptionInterface
{
public static function fromIpAddress($ipAddress, ?Throwable $prev = null): self
{
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
}
}

View file

@ -1,106 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\GeoLite2;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use PharData;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Symfony\Component\Filesystem\Exception as FilesystemException;
use Symfony\Component\Filesystem\Filesystem;
use Throwable;
use function sprintf;
class DbUpdater implements DbUpdaterInterface
{
private const DB_COMPRESSED_FILE = 'GeoLite2-City.tar.gz';
private const DB_DECOMPRESSED_FILE = 'GeoLite2-City.mmdb';
/** @var ClientInterface */
private $httpClient;
/** @var Filesystem */
private $filesystem;
/** @var GeoLite2Options */
private $options;
public function __construct(ClientInterface $httpClient, Filesystem $filesystem, GeoLite2Options $options)
{
$this->httpClient = $httpClient;
$this->filesystem = $filesystem;
$this->options = $options;
}
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(?callable $handleProgress = null): void
{
$tempDir = $this->options->getTempDir();
$compressedFile = sprintf('%s/%s', $tempDir, self::DB_COMPRESSED_FILE);
$this->downloadDbFile($compressedFile, $handleProgress);
$tempFullPath = $this->extractDbFile($compressedFile, $tempDir);
$this->copyNewDbFile($tempFullPath);
$this->deleteTempFiles([$compressedFile, $tempFullPath]);
}
private function downloadDbFile(string $dest, ?callable $handleProgress = null): void
{
try {
$this->httpClient->request(RequestMethod::METHOD_GET, $this->options->getDownloadFrom(), [
RequestOptions::SINK => $dest,
RequestOptions::PROGRESS => $handleProgress,
]);
} catch (Throwable | GuzzleException $e) {
throw new RuntimeException(
'An error occurred while trying to download a fresh copy of the GeoLite2 database',
0,
$e
);
}
}
private function extractDbFile(string $compressedFile, string $tempDir): string
{
try {
$phar = new PharData($compressedFile);
$internalPathToDb = sprintf('%s/%s', $phar->getBasename(), self::DB_DECOMPRESSED_FILE);
$phar->extractTo($tempDir, $internalPathToDb, true);
return sprintf('%s/%s', $tempDir, $internalPathToDb);
} catch (Throwable $e) {
throw new RuntimeException(
sprintf('An error occurred while trying to extract the GeoLite2 database from %s', $compressedFile),
0,
$e
);
}
}
private function copyNewDbFile(string $from): void
{
try {
$this->filesystem->copy($from, $this->options->getDbLocation(), true);
} catch (FilesystemException\FileNotFoundException | FilesystemException\IOException $e) {
throw new RuntimeException('An error occurred while trying to copy GeoLite2 db file to destination', 0, $e);
}
}
private function deleteTempFiles(array $files): void
{
try {
$this->filesystem->remove($files);
} catch (FilesystemException\IOException $e) {
// Ignore any error produced when trying to delete temp files
}
}
public function databaseFileExists(): bool
{
return $this->filesystem->exists($this->options->getDbLocation());
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\GeoLite2;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
interface DbUpdaterInterface
{
public function databaseFileExists(): bool;
/**
* @throws RuntimeException
*/
public function downloadFreshCopy(?callable $handleProgress = null): void;
}

View file

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\GeoLite2;
use Zend\Stdlib\AbstractOptions;
class GeoLite2Options extends AbstractOptions
{
private $dbLocation = '';
private $tempDir = '';
private $downloadFrom = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz';
public function getDbLocation(): string
{
return $this->dbLocation;
}
protected function setDbLocation(string $dbLocation): self
{
$this->dbLocation = $dbLocation;
return $this;
}
public function getTempDir(): string
{
return $this->tempDir;
}
protected function setTempDir(string $tempDir): self
{
$this->tempDir = $tempDir;
return $this;
}
public function getDownloadFrom(): string
{
return $this->downloadFrom;
}
protected function setDownloadFrom(string $downloadFrom): self
{
$this->downloadFrom = $downloadFrom;
return $this;
}
}

View file

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Model;
final class Location
{
/** @var string */
private $countryCode;
/** @var string */
private $countryName;
/** @var string */
private $regionName;
/** @var string */
private $city;
/** @var float */
private $latitude;
/** @var float */
private $longitude;
/** @var string */
private $timeZone;
public function __construct(
string $countryCode,
string $countryName,
string $regionName,
string $city,
float $latitude,
float $longitude,
string $timeZone
) {
$this->countryCode = $countryCode;
$this->countryName = $countryName;
$this->regionName = $regionName;
$this->city = $city;
$this->latitude = $latitude;
$this->longitude = $longitude;
$this->timeZone = $timeZone;
}
public static function emptyInstance(): self
{
return new self('', '', '', '', 0.0, 0.0, '');
}
public function countryCode(): string
{
return $this->countryCode;
}
public function countryName(): string
{
return $this->countryName;
}
public function regionName(): string
{
return $this->regionName;
}
public function city(): string
{
return $this->city;
}
public function latitude(): float
{
return $this->latitude;
}
public function longitude(): float
{
return $this->longitude;
}
public function timeZone(): string
{
return $this->timeZone;
}
}

View file

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Resolver;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model;
class ChainIpLocationResolver implements IpLocationResolverInterface
{
/** @var IpLocationResolverInterface[] */
private $resolvers;
public function __construct(IpLocationResolverInterface ...$resolvers)
{
$this->resolvers = $resolvers;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): Model\Location
{
$error = null;
foreach ($this->resolvers as $resolver) {
try {
return $resolver->resolveIpLocation($ipAddress);
} catch (WrongIpException $e) {
$error = $e;
}
}
// If this instruction is reached, it means no resolver was capable of resolving the address
throw WrongIpException::fromIpAddress($ipAddress, $error);
}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Resolver;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model;
class EmptyIpLocationResolver implements IpLocationResolverInterface
{
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): Model\Location
{
return Model\Location::emptyInstance();
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Resolver;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\City;
use GeoIp2\Record\Subdivision;
use MaxMind\Db\Reader\InvalidDatabaseException;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model;
use function Functional\first;
class GeoLite2LocationResolver implements IpLocationResolverInterface
{
/** @var Reader */
private $geoLiteDbReader;
public function __construct(Reader $geoLiteDbReader)
{
$this->geoLiteDbReader = $geoLiteDbReader;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): Model\Location
{
try {
$city = $this->geoLiteDbReader->city($ipAddress);
return $this->mapFields($city);
} catch (AddressNotFoundException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
} catch (InvalidDatabaseException $e) {
throw new WrongIpException('Provided GeoLite2 db file is invalid', 0, $e);
}
}
private function mapFields(City $city): Model\Location
{
/** @var Subdivision $region */
$region = first($city->subdivisions);
return new Model\Location(
$city->country->isoCode ?? '',
$city->country->name ?? '',
$region->name ?? '',
$city->city->name ?? '',
(float) ($city->location->latitude ?? ''),
(float) ($city->location->longitude ?? ''),
$city->location->timeZone ?? ''
);
}
}

View file

@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Resolver;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model;
use function Shlinkio\Shlink\Common\json_decode;
use function sprintf;
class IpApiLocationResolver implements IpLocationResolverInterface
{
private const SERVICE_PATTERN = 'http://ip-api.com/json/%s';
/** @var Client */
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): Model\Location
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(json_decode((string) $response->getBody()));
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
} catch (InvalidArgumentException $e) {
throw new WrongIpException('IP-API returned invalid body while locating IP address', 0, $e);
}
}
private function mapFields(array $entry): Model\Location
{
return new Model\Location(
(string) ($entry['countryCode'] ?? ''),
(string) ($entry['country'] ?? ''),
(string) ($entry['regionName'] ?? ''),
(string) ($entry['city'] ?? ''),
(float) ($entry['lat'] ?? 0.0),
(float) ($entry['lon'] ?? 0.0),
(string) ($entry['timezone'] ?? '')
);
}
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\IpGeolocation\Resolver;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model;
interface IpLocationResolverInterface
{
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): Model\Location;
}

View file

@ -1 +0,0 @@
geolite2-testing-db

View file

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\IpGeolocation\ConfigProvider;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
class ConfigProviderTest extends TestCase
{
/** @var ConfigProvider */
private $configProvider;
public function setUp(): void
{
$this->configProvider = new ConfigProvider();
}
/** @test */
public function configIsReturned(): void
{
$config = $this->configProvider->__invoke();
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey(ConfigAbstractFactory::class, $config);
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\Exception;
use Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
class WrongIpExceptionTest extends TestCase
{
/** @test */
public function fromIpAddressProperlyCreatesExceptionWithoutPrev(): void
{
$e = WrongIpException::fromIpAddress('1.2.3.4');
$this->assertEquals('Provided IP "1.2.3.4" is invalid', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
/** @test */
public function fromIpAddressProperlyCreatesExceptionWithPrev(): void
{
$prev = new Exception('Previous error');
$e = WrongIpException::fromIpAddress('1.2.3.4', $prev);
$this->assertEquals('Provided IP "1.2.3.4" is invalid', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertSame($prev, $e->getPrevious());
}
}

View file

@ -1,132 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\GeoLite2;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
use Symfony\Component\Filesystem\Exception as FilesystemException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Diactoros\Response;
class DbUpdaterTest extends TestCase
{
/** @var DbUpdater */
private $dbUpdater;
/** @var ObjectProphecy */
private $httpClient;
/** @var ObjectProphecy */
private $filesystem;
/** @var GeoLite2Options */
private $options;
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->options = new GeoLite2Options([
'temp_dir' => __DIR__ . '/../../test-resources',
'db_location' => 'db_location',
'download_from' => '',
]);
$this->dbUpdater = new DbUpdater($this->httpClient->reveal(), $this->filesystem->reveal(), $this->options);
}
/** @test */
public function anExceptionIsThrownIfFreshDbCannotBeDownloaded(): void
{
$request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class);
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage(
'An error occurred while trying to download a fresh copy of the GeoLite2 database'
);
$request->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
/** @test */
public function anExceptionIsThrownIfFreshDbCannotBeExtracted(): void
{
$this->options->tempDir = '__invalid__';
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage(
'An error occurred while trying to extract the GeoLite2 database from __invalid__/GeoLite2-City.tar.gz'
);
$request->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
/**
* @test
* @dataProvider provideFilesystemExceptions
*/
public function anExceptionIsThrownIfFreshDbCannotBeCopiedToDestination(string $e): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$copy = $this->filesystem->copy(Argument::cetera())->willThrow($e);
$this->expectException(RuntimeException::class);
$this->expectExceptionCode(0);
$this->expectExceptionMessage('An error occurred while trying to copy GeoLite2 db file to destination');
$request->shouldBeCalledOnce();
$copy->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
public function provideFilesystemExceptions(): iterable
{
yield 'file not found' => [FilesystemException\FileNotFoundException::class];
yield 'IO error' => [FilesystemException\IOException::class];
}
/** @test */
public function noExceptionsAreThrownIfEverythingWorksFine(): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$copy = $this->filesystem->copy(Argument::cetera())->will(function () {
});
$remove = $this->filesystem->remove(Argument::cetera())->will(function () {
});
$this->dbUpdater->downloadFreshCopy();
$request->shouldHaveBeenCalledOnce();
$copy->shouldHaveBeenCalledOnce();
$remove->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideExists
*/
public function databaseFileExistsChecksIfTheFilesExistsInTheFilesystem(bool $expected): void
{
$exists = $this->filesystem->exists('db_location')->willReturn($expected);
$result = $this->dbUpdater->databaseFileExists();
$this->assertEquals($expected, $result);
$exists->shouldHaveBeenCalledOnce();
}
public function provideExists(): iterable
{
return [[true], [false]];
}
}

View file

@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\Resolver;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\ChainIpLocationResolver;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
class ChainIpLocationResolverTest extends TestCase
{
/** @var ChainIpLocationResolver */
private $resolver;
/** @var ObjectProphecy */
private $firstInnerResolver;
/** @var ObjectProphecy */
private $secondInnerResolver;
public function setUp(): void
{
$this->firstInnerResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->secondInnerResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->resolver = new ChainIpLocationResolver(
$this->firstInnerResolver->reveal(),
$this->secondInnerResolver->reveal()
);
}
/** @test */
public function throwsExceptionWhenNoInnerResolverCanHandleTheResolution()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$this->expectException(WrongIpException::class);
$firstResolve->shouldBeCalledOnce();
$secondResolve->shouldBeCalledOnce();
$this->resolver->resolveIpLocation($ipAddress);
}
/** @test */
public function returnsResultOfFirstInnerResolver(): void
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willReturn(Location::emptyInstance());
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldNotHaveBeenCalled();
}
/** @test */
public function returnsResultOfSecondInnerResolver(): void
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willReturn(
Location::emptyInstance()
);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldHaveBeenCalledOnce();
}
}

View file

@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\Resolver;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\EmptyIpLocationResolver;
use function Functional\map;
use function range;
class EmptyIpLocationResolverTest extends TestCase
{
use StringUtilsTrait;
/** @var EmptyIpLocationResolver */
private $resolver;
public function setUp(): void
{
$this->resolver = new EmptyIpLocationResolver();
}
/**
* @test
* @dataProvider provideEmptyResponses
*/
public function alwaysReturnsAnEmptyLocation(string $ipAddress): void
{
$this->assertEquals(Location::emptyInstance(), $this->resolver->resolveIpLocation($ipAddress));
}
public function provideEmptyResponses(): array
{
return map(range(0, 5), function () {
return [$this->generateRandomString(15)];
});
}
}

View file

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\Resolver;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\City;
use MaxMind\Db\Reader\InvalidDatabaseException;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\GeoLite2LocationResolver;
class GeoLite2LocationResolverTest extends TestCase
{
/** @var GeoLite2LocationResolver */
private $resolver;
/** @var ObjectProphecy */
private $reader;
public function setUp(): void
{
$this->reader = $this->prophesize(Reader::class);
$this->resolver = new GeoLite2LocationResolver($this->reader->reveal());
}
/**
* @test
* @dataProvider provideReaderExceptions
*/
public function exceptionIsThrownIfReaderThrowsException(string $e, string $message): void
{
$ipAddress = '1.2.3.4';
$cityMethod = $this->reader->city($ipAddress)->willThrow($e);
$this->expectException(WrongIpException::class);
$this->expectExceptionMessage($message);
$this->expectExceptionCode(0);
$cityMethod->shouldBeCalledOnce();
$this->resolver->resolveIpLocation($ipAddress);
}
public function provideReaderExceptions(): iterable
{
yield 'invalid IP address' => [AddressNotFoundException::class, 'Provided IP "1.2.3.4" is invalid'];
yield 'invalid geolite DB' => [InvalidDatabaseException::class, 'Provided GeoLite2 db file is invalid'];
}
/** @test */
public function resolvedCityIsProperlyMapped(): void
{
$ipAddress = '1.2.3.4';
$city = new City([]);
$cityMethod = $this->reader->city($ipAddress)->willReturn($city);
$result = $this->resolver->resolveIpLocation($ipAddress);
$this->assertEquals(Location::emptyInstance(), $result);
$cityMethod->shouldHaveBeenCalledOnce();
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\IpGeolocation\Resolver;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpApiLocationResolver;
use function json_encode;
class IpApiLocationResolverTest extends TestCase
{
/** @var IpApiLocationResolver */
private $ipResolver;
/** @var ObjectProphecy */
private $client;
public function setUp(): void
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
}
/** @test */
public function correctIpReturnsDecodedInfo(): void
{
$actual = [
'countryCode' => 'bar',
'lat' => 5,
'lon' => 10,
];
$expected = new Location('bar', '', '', '', 5, 10, '');
$response = new Response();
$response->getBody()->write(json_encode($actual));
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledOnce();
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/** @test */
public function guzzleExceptionThrowsShlinkException(): void
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledOnce();
$this->expectException(WrongIpException::class);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
}

View file

@ -18,9 +18,6 @@
<testsuite name="EventDispatcher">
<directory>./module/EventDispatcher/test</directory>
</testsuite>
<testsuite name="IpGeolocation">
<directory>./module/IpGeolocation/test</directory>
</testsuite>
<testsuite name="PreviewGenerator">
<directory>./module/PreviewGenerator/test</directory>
</testsuite>