Merge pull request #258 from acelaya/feature/geolocation

Feature/geolocation
This commit is contained in:
Alejandro Celaya 2018-11-12 21:46:33 +01:00 committed by GitHub
commit d4758b0e91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1095 additions and 322 deletions

1
.gitignore vendored
View file

@ -5,5 +5,6 @@ composer.phar
vendor/
.env
data/database.sqlite
data/GeoLite2-City.mmdb
docs/swagger-ui
docker-compose.override.yml

View file

@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
This new option will be asked by the installer both for new shlink installations and for any previous shlink version which is updated.
* [#189](https://github.com/shlinkio/shlink/issues/189) and [#240](https://github.com/shlinkio/shlink/issues/240) Added new [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/)-based geolocation service which is faster and more reliable than previous one.
It does not have API limit problems, since it uses a local database file.
Previous service is still used as a fallback in case GeoLite DB does not contain any IP address.
#### Changed
* [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase.

View file

@ -104,6 +104,12 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
* Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
When shlink is installed it downloads a fresh [GeoLite2](https://dev.maxmind.com/geoip/geoip2/geolite2/) db file. Running this command will update this file.
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
@ -186,3 +192,5 @@ Available commands:
visit
visit:process Processes visits where location is not set yet
```
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)

View file

@ -24,6 +24,7 @@
"doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7",
"firebase/php-jwt": "^4.0",
"geoip2/geoip2": "^2.9",
"guzzlehttp/guzzle": "^6.2",
"lstrojny/functional-php": "^1.8",
"mikehaertl/phpwkhtmltopdf": "^2.2",
@ -47,6 +48,7 @@
"zendframework/zend-stdlib": "^3.0"
},
"require-dev": {
"devster/ubench": "^2.0",
"filp/whoops": "^2.0",
"infection/infection": "^0.11.0",
"phpstan/phpstan": "^0.10.0",

View file

@ -23,6 +23,12 @@ return [
Container\ApplicationConfigInjectionDelegator::class,
],
],
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',
'write_proxy_files' => true,
],
],
];

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
],
];

View file

@ -0,0 +1,12 @@
<?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

@ -18,6 +18,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\ProcessVisitsCommand::NAME => Command\Visit\ProcessVisitsCommand::class,
Command\Visit\UpdateDbCommand::NAME => Command\Visit\UpdateDbCommand::class,
Command\Config\GenerateCharsetCommand::NAME => Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::NAME => Command\Config\GenerateSecretCommand::class,

View file

@ -3,7 +3,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@ -25,6 +26,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\UpdateDbCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class,
Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class,
@ -65,9 +67,10 @@ return [
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpApiLocationResolver::class,
IpLocationResolverInterface::class,
'translator',
],
Command\Visit\UpdateDbCommand::class => [DbUpdater::class, 'translator'],
Command\Config\GenerateCharsetCommand::class => ['translator'],
Command\Config\GenerateSecretCommand::class => ['translator'],

Binary file not shown.

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-09-16 18:36+0200\n"
"PO-Revision-Date: 2018-09-16 18:37+0200\n"
"POT-Creation-Date: 2018-11-12 21:01+0100\n"
"PO-Revision-Date: 2018-11-12 21:03+0100\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@ -340,6 +340,9 @@ msgstr "Una etiqueta con nombre \"%s\" no ha sido encontrada"
msgid "Processes visits where location is not set yet"
msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
msgid "Ignored visit with no IP address"
msgstr "Ignorada visita sin dirección IP"
msgid "Processing IP"
msgstr "Procesando IP"
@ -350,16 +353,33 @@ msgstr "Ignorada IP de localhost"
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "An error occurred while locating IP"
msgstr "Se produjo un error al localizar la IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "An error occurred while locating IP. Skipped"
msgstr "Se produjo un error al localizar la IP. Ignorado"
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
msgid "Updates the GeoLite2 database file used to geolocate IP addresses"
msgstr ""
"Actualiza el fichero de base de datos de GeoLite2 usado para geolocalizar "
"direcciones IP"
msgid ""
"The GeoLite2 database is updated first Tuesday every month, so this command "
"should be ideally run every first Wednesday"
msgstr ""
"La base de datos de GeoLite2 se actualiza el primer Martes de cada mes, por "
"lo que la opción ideal es ejecutar este comando cada primer miércoles de mes"
msgid "GeoLite2 database properly updated"
msgstr "Base de datos de GeoLite2 correctamente actualizada"
msgid "An error occurred while updating GeoLite2 database"
msgstr "Se produjo un error al actualizar la base de datos de GeoLite2"
#~ msgid "IP location resolver limit reached. Waiting %s seconds..."
#~ msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
#~ msgid "Remote Address"
#~ msgstr "Dirección remota"

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
@ -13,7 +13,6 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
use function sleep;
use function sprintf;
class ProcessVisitsCommand extends Command
@ -41,7 +40,7 @@ class ProcessVisitsCommand extends Command
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->translator = $translator;
parent::__construct(null);
parent::__construct();
}
protected function configure(): void
@ -57,7 +56,6 @@ class ProcessVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) {
if (! $visit->hasRemoteAddr()) {
$io->writeln(
@ -68,15 +66,14 @@ class ProcessVisitsCommand extends Command
}
$ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
$io->write(sprintf('%s <fg=blue>%s</>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
sprintf(' [<comment>%s</comment>]', $this->translator->translate('Ignored localhost address'))
);
continue;
}
$count++;
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
@ -85,27 +82,20 @@ class ProcessVisitsCommand extends Command
$this->visitService->saveVisit($visit);
$io->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
' [<info>' . $this->translator->translate('Address located at "%s"') . '</info>]',
$location->getCountryName()
));
} catch (WrongIpException $e) {
$io->writeln(
sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
sprintf(
' [<fg=red>%s</>]',
$this->translator->translate('An error occurred while locating IP. Skipped')
)
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
sleep($seconds);
}
}
$io->success($this->translator->translate('Finished processing all IPs'));

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\I18n\Translator\TranslatorInterface;
class UpdateDbCommand extends Command
{
public const NAME = 'visit:update-db';
/**
* @var DbUpdaterInterface
*/
private $geoLiteDbUpdater;
/**
* @var TranslatorInterface
*/
private $translator;
public function __construct(DbUpdaterInterface $geoLiteDbUpdater, TranslatorInterface $translator)
{
$this->geoLiteDbUpdater = $geoLiteDbUpdater;
$this->translator = $translator;
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
$this->translator->translate('Updates the GeoLite2 database file used to geolocate IP addresses')
)
->setHelp($this->translator->translate(
'The GeoLite2 database is updated first Tuesday every month, so this command should be ideally run '
. 'every first Wednesday'
));
}
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$progressBar = new ProgressBar($output);
$progressBar->start();
try {
$this->geoLiteDbUpdater->downloadFreshCopy(function (int $total, int $downloaded) use ($progressBar) {
$progressBar->setMaxSteps($total);
$progressBar->setProgress($downloaded);
});
$progressBar->finish();
$io->writeln('');
$io->success($this->translator->translate('GeoLite2 database properly updated'));
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
$io->error($this->translator->translate('An error occurred while updating GeoLite2 database'));
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
}
}

View file

@ -38,7 +38,7 @@ class DisableKeyCommandTest extends TestCase
public function providedApiKeyIsDisabled()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledTimes(1);
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
@ -52,7 +52,7 @@ class DisableKeyCommandTest extends TestCase
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:disable',

View file

@ -39,7 +39,7 @@ class GenerateKeyCommandTest extends TestCase
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
$this->apiKeyService->create(null)->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
@ -51,7 +51,7 @@ class GenerateKeyCommandTest extends TestCase
*/
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledTimes(1)
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',

View file

@ -41,7 +41,7 @@ class ListKeysCommandTest extends TestCase
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
]);
@ -55,7 +55,7 @@ class ListKeysCommandTest extends TestCase
$this->apiKeyService->listKeys(true)->willReturn([
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'api-key:list',
'--enabledOnly' => true,

View file

@ -50,7 +50,7 @@ class DeleteShortCodeCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@ -67,7 +67,7 @@ class DeleteShortCodeCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@ -117,6 +117,6 @@ class DeleteShortCodeCommandTest extends TestCase
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View file

@ -60,11 +60,11 @@ class GeneratePreviewCommandTest extends TestCase
new ShortUrl('https://bar.com'),
new ShortUrl('http://baz.com/something'),
]);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledOnce();
$this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledOnce();
$this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:process-previews',
@ -82,7 +82,7 @@ class GeneratePreviewCommandTest extends TestCase
new ShortUrl('http://baz.com/something'),
];
$paginator = $this->createPaginator($items);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));

View file

@ -47,7 +47,7 @@ class GenerateShortcodeCommandTest extends TestCase
->willReturn(
(new ShortUrl(''))->setShortCode('abc123')
)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',
@ -63,7 +63,7 @@ class GenerateShortcodeCommandTest extends TestCase
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:generate',

View file

@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@ -64,7 +64,7 @@ class GetVisitsCommandTest extends TestCase
$endDate = '2016-02-01';
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',
@ -84,7 +84,7 @@ class GetVisitsCommandTest extends TestCase
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->setVisitLocation(
new VisitLocation(['country_name' => 'Spain'])
),
])->shouldBeCalledTimes(1);
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:visits',

View file

@ -41,7 +41,7 @@ class ListShortcodesCommandTest extends TestCase
public function noInputCallsListJustOnce()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
@ -78,7 +78,7 @@ class ListShortcodesCommandTest extends TestCase
}
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['n']);
$this->commandTester->execute(['command' => 'shortcode:list']);
@ -91,7 +91,7 @@ class ListShortcodesCommandTest extends TestCase
{
$page = 5;
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([
@ -106,7 +106,7 @@ class ListShortcodesCommandTest extends TestCase
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->setInputs(['y']);
$this->commandTester->execute([

View file

@ -45,7 +45,7 @@ class ResolveUrlCommandTest extends TestCase
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
@ -62,7 +62,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',
@ -79,7 +79,7 @@ class ResolveUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => 'shortcode:parse',

View file

@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
@ -17,28 +17,26 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use function count;
use function round;
class ProcessVisitsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
private $commandTester;
/**
* @var ObjectProphecy
*/
protected $visitService;
private $visitService;
/**
* @var ObjectProphecy
*/
protected $ipResolver;
private $ipResolver;
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->ipResolver->getApiLimit()->willReturn(10000000000);
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
@ -64,7 +62,7 @@ class ProcessVisitsCommandTest extends TestCase
new Visit($shortUrl, new Visitor('', '', '12.34.56.78')),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits));
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
@ -96,7 +94,7 @@ class ProcessVisitsCommandTest extends TestCase
new Visit($shortUrl, new Visitor('', '', null)),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 4);
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
@ -109,43 +107,4 @@ class ProcessVisitsCommandTest extends TestCase
$this->assertContains('Ignored localhost address', $output);
$this->assertContains('Ignored visit with no IP address', $output);
}
/**
* @test
*/
public function sleepsEveryTimeTheApiLimitIsReached()
{
$shortUrl = new ShortUrl('');
$visits = [
new Visit($shortUrl, new Visitor('', '', '1.2.3.4')),
new Visit($shortUrl, new Visitor('', '', '4.3.2.1')),
new Visit($shortUrl, new Visitor('', '', '12.34.56.78')),
new Visit($shortUrl, new Visitor('', '', '1.2.3.4')),
new Visit($shortUrl, new Visitor('', '', '4.3.2.1')),
new Visit($shortUrl, new Visitor('', '', '12.34.56.78')),
new Visit($shortUrl, new Visitor('', '', '1.2.3.4')),
new Visit($shortUrl, new Visitor('', '', '4.3.2.1')),
new Visit($shortUrl, new Visitor('', '', '12.34.56.78')),
new Visit($shortUrl, new Visitor('', '', '4.3.2.1')),
];
$apiLimit = 3;
$this->visitService->getUnlocatedVisits()->willReturn($visits);
$this->visitService->saveVisit(Argument::any())->will(function () {
});
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$getApiLimit->shouldHaveBeenCalledTimes(count($visits));
$getApiInterval->shouldHaveBeenCalledTimes(round(count($visits) / $apiLimit));
$resolveIpLocation->shouldHaveBeenCalledTimes(count($visits));
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\UpdateDbCommand;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class UpdateDbCommandTest extends TestCase
{
/**
* @var CommandTester
*/
private $commandTester;
/**
* @var ObjectProphecy
*/
private $dbUpdater;
public function setUp()
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$command = new UpdateDbCommand($this->dbUpdater->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfEverythingWorks()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->will(function () {
});
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('GeoLite2 database properly updated', $output);
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function errorMessageIsPrintedIfAnExceptionIsThrown()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('An error occurred while updating GeoLite2 database', $output);
$download->shouldHaveBeenCalledOnce();
}
}

View file

@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Common;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use GeoIp2\Database\Reader;
use GuzzleHttp\Client as GuzzleClient;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
@ -13,6 +14,7 @@ use Symfony\Component\Filesystem\Filesystem;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\Proxy\LazyServiceFactory;
return [
@ -23,6 +25,7 @@ return [
Cache::class => Factory\CacheFactory::class,
'Logger_Shlink' => Factory\LoggerFactory::class,
Filesystem::class => InvokableFactory::class,
Reader::class => ConfigAbstractFactory::class,
Translator::class => Factory\TranslatorFactory::class,
Template\Extension\TranslatorExtension::class => ConfigAbstractFactory::class,
@ -32,26 +35,64 @@ return [
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\IpApiLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2LocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\EmptyIpLocationResolver::class => InvokableFactory::class,
IpGeolocation\ChainIpLocationResolver::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2\GeoLite2Options::class => ConfigAbstractFactory::class,
IpGeolocation\GeoLite2\DbUpdater::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleClient::class,
'translator' => Translator::class,
'logger' => LoggerInterface::class,
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
IpGeolocation\IpLocationResolverInterface::class => IpGeolocation\ChainIpLocationResolver::class,
],
'abstract_factories' => [
Factory\DottedAccessConfigAbstractFactory::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'],
Template\Extension\TranslatorExtension::class => ['translator'],
Middleware\LocaleMiddleware::class => ['translator'],
Service\IpApiLocationResolver::class => ['httpClient'],
IpGeolocation\IpApiLocationResolver::class => ['httpClient'],
IpGeolocation\GeoLite2LocationResolver::class => [Reader::class],
IpGeolocation\ChainIpLocationResolver::class => [
IpGeolocation\GeoLite2LocationResolver::class,
IpGeolocation\IpApiLocationResolver::class,
IpGeolocation\EmptyIpLocationResolver::class,
],
IpGeolocation\GeoLite2\GeoLite2Options::class => ['config.geolite2'],
IpGeolocation\GeoLite2\DbUpdater::class => [
GuzzleClient::class,
Filesystem::class,
IpGeolocation\GeoLite2\GeoLite2Options::class,
],
Service\PreviewGenerator::class => [
Image\ImageBuilder::class,
Filesystem::class,

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class ChainIpLocationResolver implements IpLocationResolverInterface
{
/**
* @var IpLocationResolverInterface[]
*/
private $resolvers;
public function __construct(IpLocationResolverInterface ...$resolvers)
{
$this->resolvers = $resolvers;
}
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
$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

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class EmptyIpLocationResolver implements IpLocationResolverInterface
{
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
return [
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
];
}
}

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation\GeoLite2;
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use PharData;
use Shlinkio\Shlink\Common\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
}
}
}

View file

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

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\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

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\City;
use GeoIp2\Record\Subdivision;
use MaxMind\Db\Reader\InvalidDatabaseException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
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): array
{
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): array
{
/** @var Subdivision $region */
$region = first($city->subdivisions);
return [
'country_code' => $city->country->isoCode ?? '',
'country_name' => $city->country->name ?? '',
'region_name' => $region->name ?? '',
'city' => $city->city->name ?? '',
'latitude' => $city->location->latitude ?? '',
'longitude' => $city->location->longitude ?? '',
'time_zone' => $city->location->timeZone ?? '',
];
}
}

View file

@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
namespace Shlinkio\Shlink\Common\IpGeolocation;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
@ -25,8 +25,6 @@ class IpApiLocationResolver implements IpLocationResolverInterface
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
@ -53,24 +51,4 @@ class IpApiLocationResolver implements IpLocationResolverInterface
'time_zone' => $entry['timezone'] ?? '',
];
}
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int
{
return 65; // ip-api interval is 1 minute. Return 5 extra seconds just in case
}
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int
{
return 145; // ip-api limit is 150 requests per minute. Leave 5 less requests just in case
}
}

View file

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

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
interface IpLocationResolverInterface
{
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int;
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int;
}

View file

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

Binary file not shown.

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\ChainIpLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
class ChainIpLocationResolverTest extends TestCase
{
/**
* @var ChainIpLocationResolver
*/
private $resolver;
/**
* @var ObjectProphecy
*/
private $firstInnerResolver;
/**
* @var ObjectProphecy
*/
private $secondInnerResolver;
public function setUp()
{
$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()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function returnsResultOfSecondInnerResolver()
{
$ipAddress = '1.2.3.4';
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
$this->resolver->resolveIpLocation($ipAddress);
$firstResolve->shouldHaveBeenCalledOnce();
$secondResolve->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\IpGeolocation\EmptyIpLocationResolver;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use function Functional\map;
use function range;
class EmptyIpLocationResolverTest extends TestCase
{
use StringUtilsTrait;
private const EMPTY_RESP = [
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
];
/**
* @var EmptyIpLocationResolver
*/
private $resolver;
public function setUp()
{
$this->resolver = new EmptyIpLocationResolver();
}
/**
* @test
* @dataProvider provideEmptyResponses
*/
public function alwaysReturnsAnEmptyResponse(array $expected, string $ipAddress)
{
$this->assertEquals($expected, $this->resolver->resolveIpLocation($ipAddress));
}
public function provideEmptyResponses(): array
{
return map(range(0, 5), function () {
return [self::EMPTY_RESP, $this->generateRandomString(10)];
});
}
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation\GeoLite2;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\Common\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()
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->options = new GeoLite2Options([
'temp_dir' => __DIR__ . '/../../../test-resources',
'db_location' => '',
'download_from' => '',
]);
$this->dbUpdater = new DbUpdater($this->httpClient->reveal(), $this->filesystem->reveal(), $this->options);
}
/**
* @test
*/
public function anExceptionIsThrownIfFreshDbCannotBeDownloaded()
{
$request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class);
$this->expectException(RuntimeException::class);
$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()
{
$this->options->tempDir = '__invalid__';
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$this->expectException(RuntimeException::class);
$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)
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(new Response());
$copy = $this->filesystem->copy(Argument::cetera())->willThrow($e);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('An error occurred while trying to copy GeoLite2 db file to destination');
$request->shouldBeCalledOnce();
$copy->shouldBeCalledOnce();
$this->dbUpdater->downloadFreshCopy();
}
public function provideFilesystemExceptions(): array
{
return [
[FilesystemException\FileNotFoundException::class],
[FilesystemException\IOException::class],
];
}
/**
* @test
*/
public function noExceptionsAreThrownIfEverythingWorksFine()
{
$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();
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
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\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2LocationResolver;
class GeoLite2LocationResolverTest extends TestCase
{
/**
* @var GeoLite2LocationResolver
*/
private $resolver;
/**
* @var ObjectProphecy
*/
private $reader;
public function setUp()
{
$this->reader = $this->prophesize(Reader::class);
$this->resolver = new GeoLite2LocationResolver($this->reader->reveal());
}
/**
* @test
* @dataProvider provideReaderExceptions
*/
public function exceptionIsThrownIfReaderThrowsException(string $e, string $message)
{
$ipAddress = '1.2.3.4';
$cityMethod = $this->reader->city($ipAddress)->willThrow($e);
$this->expectException(WrongIpException::class);
$this->expectExceptionMessage($message);
$cityMethod->shouldBeCalledOnce();
$this->resolver->resolveIpLocation($ipAddress);
}
public function provideReaderExceptions(): array
{
return [
[AddressNotFoundException::class, 'Provided IP "1.2.3.4" is invalid'],
[InvalidDatabaseException::class, 'Provided GeoLite2 db file is invalid'],
];
}
/**
* @test
*/
public function resolvedCityIsProperlyMapped()
{
$ipAddress = '1.2.3.4';
$city = new City([]);
$cityMethod = $this->reader->city($ipAddress)->willReturn($city);
$result = $this->resolver->resolveIpLocation($ipAddress);
$this->assertEquals([
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
], $result);
$cityMethod->shouldHaveBeenCalledOnce();
}
}

View file

@ -1,14 +1,14 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
namespace ShlinkioTest\Shlink\Common\IpGeolocation;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use function json_encode;
class IpApiLocationResolverTest extends TestCase
@ -52,7 +52,7 @@ class IpApiLocationResolverTest extends TestCase
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
@ -63,23 +63,7 @@ class IpApiLocationResolverTest extends TestCase
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
/**
* @test
*/
public function getApiIntervalReturnsExpectedValue()
{
$this->assertEquals(65, $this->ipResolver->getApiInterval());
}
/**
* @test
*/
public function getApiLimitReturnsExpectedValue()
{
$this->assertEquals(145, $this->ipResolver->getApiLimit());
}
}

View file

@ -30,7 +30,7 @@ class PaginableRepositoryAdapterTest extends TestCase
*/
public function getItemsFallbacksToFindList()
{
$this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order')->shouldBeCalledTimes(1);
$this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order')->shouldBeCalledOnce();
$this->adapter->getItems(5, 10);
}
@ -39,7 +39,7 @@ class PaginableRepositoryAdapterTest extends TestCase
*/
public function countFallbacksToCountList()
{
$this->repo->countList('search', ['foo', 'bar'])->shouldBeCalledTimes(1);
$this->repo->countList('search', ['foo', 'bar'])->shouldBeCalledOnce();
$this->adapter->count();
}
}

View file

@ -50,7 +50,7 @@ class PreviewGeneratorTest extends TestCase
{
$url = 'http://foo.com';
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(true)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->image->saveAs(Argument::cetera())->shouldBeCalledTimes(0);
$this->assertEquals(sprintf('dir/preview_%s.png', urlencode($url)), $this->generator->generatePreview($url));
}
@ -65,10 +65,10 @@ class PreviewGeneratorTest extends TestCase
$expectedPath = 'dir/' . $cacheId;
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
$this->image->getError()->willReturn('')->shouldBeCalledTimes(1);
$this->image->saveAs($expectedPath)->shouldBeCalledOnce();
$this->image->getError()->willReturn('')->shouldBeCalledOnce();
$this->assertEquals($expectedPath, $this->generator->generatePreview($url));
}
@ -83,10 +83,10 @@ class PreviewGeneratorTest extends TestCase
$expectedPath = 'dir/' . $cacheId;
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
$this->image->getError()->willReturn('Error!!')->shouldBeCalledTimes(1);
$this->image->saveAs($expectedPath)->shouldBeCalledOnce();
$this->image->getError()->willReturn('Error!!')->shouldBeCalledOnce();
$this->generator->generatePreview($url);
}

View file

@ -28,8 +28,8 @@ class TranslatorExtensionTest extends TestCase
{
$engine = $this->prophesize(Engine::class);
$engine->registerFunction('translate', Argument::type('callable'))->shouldBeCalledTimes(1);
$engine->registerFunction('translate_plural', Argument::type('callable'))->shouldBeCalledTimes(1);
$engine->registerFunction('translate', Argument::type('callable'))->shouldBeCalledOnce();
$engine->registerFunction('translate_plural', Argument::type('callable'))->shouldBeCalledOnce();
$funcs = $this->extension->register($engine->reveal());
}

View file

@ -51,8 +51,8 @@ class PixelActionTest extends TestCase
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
new ShortUrl('http://domain.com/foo/bar')
)->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->shouldBeCalledTimes(1);
)->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal());

View file

@ -50,9 +50,9 @@ class PreviewActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$delegate->handle(Argument::cetera())->shouldBeCalledTimes(1)
$delegate->handle(Argument::cetera())->shouldBeCalledOnce()
->willReturn(new Response());
$this->action->process(
@ -70,8 +70,8 @@ class PreviewActionTest extends TestCase
$url = 'foobar.com';
$shortUrl = new ShortUrl($url);
$path = __FILE__;
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledTimes(1);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)->shouldBeCalledOnce();
$this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledOnce();
$resp = $this->action->process(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
@ -89,7 +89,7 @@ class PreviewActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
/** @var MethodProphecy $process */
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -99,6 +99,6 @@ class PreviewActionTest extends TestCase
$delegate->reveal()
);
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
}

View file

@ -46,7 +46,7 @@ class QrCodeActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -55,7 +55,7 @@ class QrCodeActionTest extends TestCase
$delegate->reveal()
);
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
/**
@ -65,7 +65,7 @@ class QrCodeActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
/** @var MethodProphecy $process */
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -75,7 +75,7 @@ class QrCodeActionTest extends TestCase
$delegate->reveal()
);
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
/**
@ -85,7 +85,7 @@ class QrCodeActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(new ShortUrl(''))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process(

View file

@ -59,8 +59,8 @@ class RedirectActionTest extends TestCase
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledTimes(1);
$this->visitTracker->track(Argument::cetera())->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, TestUtils::createReqHandlerMock()->reveal());
@ -78,7 +78,7 @@ class RedirectActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$handler = $this->prophesize(RequestHandlerInterface::class);
@ -87,7 +87,7 @@ class RedirectActionTest extends TestCase
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$this->action->process($request, $handler->reveal());
$handle->shouldHaveBeenCalledTimes(1);
$handle->shouldHaveBeenCalledOnce();
}
/**
@ -111,7 +111,7 @@ class RedirectActionTest extends TestCase
$this->assertEquals(302, $resp->getStatusCode());
$this->assertEquals('https://shlink.io', $resp->getHeaderLine('Location'));
$shortCodeToUrl->shouldHaveBeenCalledTimes(1);
$shortCodeToUrl->shouldHaveBeenCalledOnce();
$handle->shouldNotHaveBeenCalled();
}
@ -124,7 +124,7 @@ class RedirectActionTest extends TestCase
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($shortUrl)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode)

View file

@ -36,7 +36,7 @@ class QrCodeCacheMiddlewareTest extends TestCase
public function noCachedPathFallsBackToNextMiddleware()
{
$delegate = $this->prophesize(RequestHandlerInterface::class);
$delegate->handle(Argument::any())->willReturn(new Response())->shouldBeCalledTimes(1);
$delegate->handle(Argument::any())->willReturn(new Response())->shouldBeCalledOnce();
$this->middleware->process(ServerRequestFactory::fromGlobals()->withUri(
new Uri('/foo/bar')

View file

@ -70,8 +70,8 @@ class DeleteShortUrlServiceTest extends TestCase
$service->deleteByShortCode('abc123', true);
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
}
/**
@ -86,8 +86,8 @@ class DeleteShortUrlServiceTest extends TestCase
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
}
/**
@ -102,8 +102,8 @@ class DeleteShortUrlServiceTest extends TestCase
$service->deleteByShortCode('abc123');
$remove->shouldHaveBeenCalledTimes(1);
$flush->shouldHaveBeenCalledTimes(1);
$remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
}
private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService

View file

@ -49,8 +49,8 @@ class ShortUrlServiceTest extends TestCase
];
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findList(Argument::cetera())->willReturn($list)->shouldBeCalledTimes(1);
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledTimes(1);
$repo->findList(Argument::cetera())->willReturn($list)->shouldBeCalledOnce();
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$list = $this->service->listShortUrls();
@ -65,7 +65,7 @@ class ShortUrlServiceTest extends TestCase
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(null)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(InvalidShortCodeException::class);
@ -78,16 +78,16 @@ class ShortUrlServiceTest extends TestCase
public function providedTagsAreGetFromRepoAndSetToTheShortUrl()
{
$shortUrl = $this->prophesize(ShortUrl::class);
$shortUrl->setTags(Argument::any())->shouldBeCalledTimes(1);
$shortUrl->setTags(Argument::any())->shouldBeCalledOnce();
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl->reveal())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$tagRepo = $this->prophesize(EntityRepository::class);
$tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldbeCalledTimes(1);
$tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldbeCalledTimes(1);
$tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce();
$tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce();
$this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
$this->service->setTagsByShortCode($shortCode, ['foo', 'bar']);

View file

@ -98,8 +98,8 @@ class UrlShortenerTest extends TestCase
$conn = $this->prophesize(Connection::class);
$conn->isTransactionActive()->willReturn(true);
$this->em->getConnection()->willReturn($conn->reveal());
$this->em->rollback()->shouldBeCalledTimes(1);
$this->em->close()->shouldBeCalledTimes(1);
$this->em->rollback()->shouldBeCalledOnce();
$this->em->close()->shouldBeCalledOnce();
$this->em->flush()->willThrow(new ORMException());
$this->urlShortener->urlToShortCode(new Uri('http://foobar.com/12345/hello?foo=bar'));
@ -135,7 +135,7 @@ class UrlShortenerTest extends TestCase
'custom-slug'
);
$slugify->shouldHaveBeenCalledTimes(1);
$slugify->shouldHaveBeenCalledOnce();
}
/**
@ -153,8 +153,8 @@ class UrlShortenerTest extends TestCase
/** @var MethodProphecy $getRepo */
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$slugify->shouldBeCalledTimes(1);
$findBySlug->shouldBeCalledTimes(1);
$slugify->shouldBeCalledOnce();
$findBySlug->shouldBeCalledOnce();
$getRepo->shouldBeCalled();
$this->expectException(NonUniqueSlugException::class);

View file

@ -35,8 +35,8 @@ class VisitServiceTest extends TestCase
public function saveVisitsPersistsProvidedVisit()
{
$visit = new Visit(new ShortUrl(''), Visitor::emptyInstance());
$this->em->persist($visit)->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->persist($visit)->shouldBeCalledOnce();
$this->em->flush()->shouldBeCalledOnce();
$this->visitService->saveVisit($visit);
}
@ -46,8 +46,8 @@ class VisitServiceTest extends TestCase
public function getUnlocatedVisitsFallbacksToRepository()
{
$repo = $this->prophesize(VisitRepository::class);
$repo->findUnlocatedVisits()->shouldBeCalledTimes(1);
$this->em->getRepository(Visit::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$repo->findUnlocatedVisits()->shouldBeCalledOnce();
$this->em->getRepository(Visit::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->visitService->getUnlocatedVisits();
}
}

View file

@ -40,9 +40,9 @@ class VisitsTrackerTest extends TestCase
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->em->persist(Argument::any())->shouldBeCalledTimes(1);
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->em->persist(Argument::any())->shouldBeCalledOnce();
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledOnce();
$this->visitsTracker->track($shortCode, Visitor::emptyInstance());
}
@ -57,13 +57,13 @@ class VisitsTrackerTest extends TestCase
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->em->persist(Argument::any())->will(function ($args) use ($test) {
/** @var Visit $visit */
$visit = $args[0];
$test->assertEquals('4.3.2.0', $visit->getRemoteAddr());
})->shouldBeCalledTimes(1);
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1);
})->shouldBeCalledOnce();
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledOnce();
$this->visitsTracker->track($shortCode, new Visitor('', '', '4.3.2.1'));
}
@ -77,7 +77,7 @@ class VisitsTrackerTest extends TestCase
$shortUrl = new ShortUrl('http://domain.com/foo/bar');
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$list = [
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
@ -85,7 +85,7 @@ class VisitsTrackerTest extends TestCase
];
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByShortUrl($shortUrl, null)->willReturn($list);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledTimes(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$this->assertEquals($list, $this->visitsTracker->info($shortCode));
}

View file

@ -10,7 +10,6 @@ use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
@ -149,7 +148,7 @@ class InstallCommand extends Command
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runPhpCommand(
if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.',
$output
@ -160,7 +159,7 @@ class InstallCommand extends Command
// Run database migrations
$this->io->write('Updating database...');
if (! $this->runPhpCommand(
if (! $this->execPhp(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.',
$output
@ -170,7 +169,7 @@ class InstallCommand extends Command
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->runPhpCommand(
if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
@ -178,6 +177,12 @@ class InstallCommand extends Command
return;
}
// Download GeoLite2 db filte
$this->io->write('Downloading GeoLite2 db...');
if (! $this->execPhp('bin/cli visit:update-db', 'Error downloading GeoLite2 db.', $output)) {
return;
}
$this->io->success('Installation complete!');
}
@ -226,15 +231,7 @@ class InstallCommand extends Command
return $config;
}
/**
* @param string $command
* @param string $errorMessage
* @param OutputInterface $output
* @return bool
* @throws LogicException
* @throws InvalidArgumentException
*/
private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
private function execPhp(string $command, string $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
@ -256,7 +253,7 @@ class InstallCommand extends Command
}
if (! $this->io->isVerbose()) {
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
}
return false;

View file

@ -81,7 +81,7 @@ class InstallCommandTest extends TestCase
*/
public function generatedConfigIsProperlyPersisted()
{
$this->configWriter->toFile(Argument::any(), Argument::type('array'), false)->shouldBeCalledTimes(1);
$this->configWriter->toFile(Argument::any(), Argument::type('array'), false)->shouldBeCalledOnce();
$this->commandTester->execute([]);
}
@ -97,8 +97,8 @@ class InstallCommandTest extends TestCase
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledTimes(1);
$appConfigRemove->shouldHaveBeenCalledTimes(1);
$appConfigExists->shouldHaveBeenCalledOnce();
$appConfigRemove->shouldHaveBeenCalledOnce();
}
/**
@ -115,8 +115,8 @@ class InstallCommandTest extends TestCase
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledTimes(1);
$appConfigRemove->shouldHaveBeenCalledTimes(1);
$appConfigExists->shouldHaveBeenCalledOnce();
$appConfigRemove->shouldHaveBeenCalledOnce();
$configToFile->shouldNotHaveBeenCalled();
}

View file

@ -51,7 +51,7 @@ class ApplicationConfigCustomizerTest extends TestCase
'CHECK_VISITS_THRESHOLD' => false,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledOnce();
}
/**
@ -77,7 +77,7 @@ class ApplicationConfigCustomizerTest extends TestCase
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(3);
$confirm->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledOnce();
}
/**
@ -101,7 +101,7 @@ class ApplicationConfigCustomizerTest extends TestCase
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledOnce();
}
/**

View file

@ -55,7 +55,7 @@ class DatabaseConfigCustomizerTest extends TestCase
'HOST' => 'param',
'PORT' => 'param',
], $config->getDatabase());
$choice->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledOnce();
$ask->shouldHaveBeenCalledTimes(5);
}
@ -137,6 +137,6 @@ class DatabaseConfigCustomizerTest extends TestCase
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$copy->shouldHaveBeenCalledTimes(1);
$copy->shouldHaveBeenCalledOnce();
}
}

View file

@ -63,7 +63,7 @@ class LanguageConfigCustomizerTest extends TestCase
'DEFAULT' => 'en',
'CLI' => 'es',
], $config->getLanguage());
$choice->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledOnce();
}
/**

View file

@ -50,7 +50,7 @@ class UrlShortenerConfigCustomizerTest extends TestCase
'NOT_FOUND_REDIRECT_TO' => 'asked',
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(3);
$choice->shouldHaveBeenCalledTimes(1);
$choice->shouldHaveBeenCalledOnce();
$confirm->shouldHaveBeenCalledTimes(2);
}
@ -81,8 +81,8 @@ class UrlShortenerConfigCustomizerTest extends TestCase
'NOT_FOUND_REDIRECT_TO' => 'foo',
], $config->getUrlShortener());
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledTimes(1);
$ask->shouldHaveBeenCalledOnce();
$confirm->shouldHaveBeenCalledOnce();
}
/**
@ -146,6 +146,6 @@ class UrlShortenerConfigCustomizerTest extends TestCase
'ENABLE_NOT_FOUND_REDIRECTION' => false,
], $config->getUrlShortener());
$ask->shouldNotHaveBeenCalled();
$confirm->shouldHaveBeenCalledTimes(1);
$confirm->shouldHaveBeenCalledOnce();
}
}

View file

@ -57,7 +57,7 @@ class AuthenticateActionTest extends TestCase
public function properApiKeyReturnsTokenInResponse()
{
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId('5'))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'apiKey' => 'foo',
@ -75,7 +75,7 @@ class AuthenticateActionTest extends TestCase
public function invalidApiKeyReturnsErrorResponse()
{
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->disable())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'apiKey' => 'foo',

View file

@ -56,7 +56,7 @@ class CreateShortUrlActionTest extends TestCase
->willReturn(
(new ShortUrl(''))->setShortCode('abc123')
)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',
@ -73,7 +73,7 @@ class CreateShortUrlActionTest extends TestCase
{
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
->willThrow(InvalidUrlException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',
@ -95,7 +95,7 @@ class CreateShortUrlActionTest extends TestCase
null,
'foo',
Argument::cetera()
)->willThrow(NonUniqueSlugException::class)->shouldBeCalledTimes(1);
)->willThrow(NonUniqueSlugException::class)->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',
@ -113,7 +113,7 @@ class CreateShortUrlActionTest extends TestCase
{
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
->willThrow(Exception::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',

View file

@ -43,7 +43,7 @@ class DeleteShortUrlActionTest extends TestCase
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$this->assertEquals(204, $resp->getStatusCode());
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
@ -60,7 +60,7 @@ class DeleteShortUrlActionTest extends TestCase
$this->assertEquals($statusCode, $resp->getStatusCode());
$this->assertEquals($error, $payload['error']);
$deleteByShortCode->shouldHaveBeenCalledTimes(1);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
public function provideExceptions(): array

View file

@ -45,7 +45,7 @@ class EditShortUrlTagsActionTest extends TestCase
{
$shortCode = 'abc123';
$this->shortUrlService->setTagsByShortCode($shortCode, [])->willThrow(InvalidShortCodeException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123')
@ -61,7 +61,7 @@ class EditShortUrlTagsActionTest extends TestCase
{
$shortCode = 'abc123';
$this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl(''))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123')

View file

@ -40,7 +40,7 @@ class ListShortUrlsActionTest extends TestCase
{
$page = 3;
$this->service->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams([
'page' => $page,
@ -55,7 +55,7 @@ class ListShortUrlsActionTest extends TestCase
{
$page = 3;
$this->service->listShortUrls($page, null, [], null)->willThrow(Exception::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams([
'page' => $page,

View file

@ -40,7 +40,7 @@ class ResolveShortUrlActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->handle($request);
@ -56,7 +56,7 @@ class ResolveShortUrlActionTest extends TestCase
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(
new ShortUrl('http://domain.com/foo/bar')
)->shouldBeCalledTimes(1);
)->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->handle($request);
@ -71,7 +71,7 @@ class ResolveShortUrlActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->handle($request);
@ -86,7 +86,7 @@ class ResolveShortUrlActionTest extends TestCase
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(Exception::class)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->handle($request);

View file

@ -39,7 +39,7 @@ class GetVisitsActionTest extends TestCase
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode));
$this->assertEquals(200, $response->getStatusCode());
@ -53,7 +53,7 @@ class GetVisitsActionTest extends TestCase
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
InvalidArgumentException::class
)->shouldBeCalledTimes(1);
)->shouldBeCalledOnce();
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode));
$this->assertEquals(404, $response->getStatusCode());
@ -67,7 +67,7 @@ class GetVisitsActionTest extends TestCase
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
Exception::class
)->shouldBeCalledTimes(1);
)->shouldBeCalledOnce();
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode));
$this->assertEquals(500, $response->getStatusCode());
@ -81,7 +81,7 @@ class GetVisitsActionTest extends TestCase
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new DateRange(null, Chronos::parse('2016-01-01 00:00:00')))
->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$response = $this->action->handle(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode)

View file

@ -37,7 +37,7 @@ class ApiKeyHeaderPluginTest extends TestCase
{
$apiKey = 'abc-ABC';
$check = $this->apiKeyService->check($apiKey)->willReturn(false);
$check->shouldBeCalledTimes(1);
$check->shouldBeCalledOnce();
$this->expectException(VerifyAuthenticationException::class);
$this->expectExceptionMessage('Provided API key does not exist or is invalid');
@ -55,7 +55,7 @@ class ApiKeyHeaderPluginTest extends TestCase
$this->plugin->verify($this->createRequest($apiKey));
$check->shouldHaveBeenCalledTimes(1);
$check->shouldHaveBeenCalledOnce();
}
/**

View file

@ -90,7 +90,7 @@ class AuthorizationHeaderPluginTest extends TestCase
$this->plugin->verify($request);
$jwtVerify->shouldHaveBeenCalledTimes(1);
$jwtVerify->shouldHaveBeenCalledOnce();
}
/**
@ -107,7 +107,7 @@ class AuthorizationHeaderPluginTest extends TestCase
$this->plugin->verify($request);
$jwtVerify->shouldHaveBeenCalledTimes(1);
$jwtVerify->shouldHaveBeenCalledOnce();
}
/**
@ -126,6 +126,6 @@ class AuthorizationHeaderPluginTest extends TestCase
$this->assertTrue($response->hasHeader(AuthorizationHeaderPlugin::HEADER_NAME));
$this->assertEquals('Bearer DEF-def', $response->getHeaderLine(AuthorizationHeaderPlugin::HEADER_NAME));
$jwtRefresh->shouldHaveBeenCalledTimes(1);
$jwtRefresh->shouldHaveBeenCalledOnce();
}
}

View file

@ -64,7 +64,7 @@ class RequestToAuthPluginTest extends TestCase
$this->requestToPlugin->fromRequest($request);
$getPlugin->shouldHaveBeenCalledTimes(1);
$getPlugin->shouldHaveBeenCalledOnce();
}
public function provideHeaders(): array

View file

@ -68,7 +68,7 @@ class AuthenticationMiddlewareTest extends TestCase
$this->middleware->process($request, $handler->reveal());
$handle->shouldHaveBeenCalledTimes(1);
$handle->shouldHaveBeenCalledOnce();
$fromRequest->shouldNotHaveBeenCalled();
}
@ -116,7 +116,7 @@ class AuthenticationMiddlewareTest extends TestCase
'Expected one of the following authentication headers, but none were provided, ["%s"]',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
), $payload['message']);
$fromRequest->shouldHaveBeenCalledTimes(1);
$fromRequest->shouldHaveBeenCalledOnce();
}
public function provideExceptions(): array
@ -150,8 +150,8 @@ class AuthenticationMiddlewareTest extends TestCase
$this->assertEquals('the_error', $payload['error']);
$this->assertEquals('the_message', $payload['message']);
$verify->shouldHaveBeenCalledTimes(1);
$fromRequest->shouldHaveBeenCalledTimes(1);
$verify->shouldHaveBeenCalledOnce();
$fromRequest->shouldHaveBeenCalledOnce();
}
/**
@ -176,10 +176,10 @@ class AuthenticationMiddlewareTest extends TestCase
$response = $this->middleware->process($request, $handler->reveal());
$this->assertSame($response, $newResponse);
$verify->shouldHaveBeenCalledTimes(1);
$update->shouldHaveBeenCalledTimes(1);
$handle->shouldHaveBeenCalledTimes(1);
$fromRequest->shouldHaveBeenCalledTimes(1);
$verify->shouldHaveBeenCalledOnce();
$update->shouldHaveBeenCalledOnce();
$handle->shouldHaveBeenCalledOnce();
$fromRequest->shouldHaveBeenCalledOnce();
}
private function getDummyMiddleware(): MiddlewareInterface

View file

@ -38,7 +38,7 @@ class BodyParserMiddlewareTest extends TestCase
$this->middleware->process($request, $delegate->reveal());
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
/**
@ -70,7 +70,7 @@ class BodyParserMiddlewareTest extends TestCase
$this->middleware->process($request, $delegate->reveal());
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
/**
@ -101,6 +101,6 @@ class BodyParserMiddlewareTest extends TestCase
$this->middleware->process($request, $delegate->reveal());
$process->shouldHaveBeenCalledTimes(1);
$process->shouldHaveBeenCalledOnce();
}
}

View file

@ -34,7 +34,7 @@ class CrossDomainMiddlewareTest extends TestCase
public function nonCrossDomainRequestsAreNotAffected()
{
$originalResponse = new Response();
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldbeCalledTimes(1);
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process(ServerRequestFactory::fromGlobals(), $this->delegate->reveal());
$this->assertSame($originalResponse, $response);
@ -50,7 +50,7 @@ class CrossDomainMiddlewareTest extends TestCase
public function anyRequestIncludesTheAllowAccessHeader()
{
$originalResponse = new Response();
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldbeCalledTimes(1);
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process(
ServerRequestFactory::fromGlobals()->withHeader('Origin', 'local'),
@ -70,7 +70,7 @@ class CrossDomainMiddlewareTest extends TestCase
{
$originalResponse = new Response();
$request = ServerRequestFactory::fromGlobals()->withMethod('OPTIONS')->withHeader('Origin', 'local');
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldbeCalledTimes(1);
$this->delegate->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce();
$response = $this->middleware->process($request, $this->delegate->reveal());
$this->assertNotSame($originalResponse, $response);

View file

@ -45,6 +45,6 @@ class ShortCodePathMiddlewareTest extends TestCase
$this->middleware->process($request->reveal(), $this->requestHandler->reveal());
$withUri->shouldHaveBeenCalledTimes(1);
$withUri->shouldHaveBeenCalledOnce();
}
}

View file

@ -34,8 +34,8 @@ class ApiKeyServiceTest extends TestCase
*/
public function keyIsProperlyCreated()
{
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
$key = $this->service->create();
$this->assertNull($key->getExpirationDate());
@ -46,8 +46,8 @@ class ApiKeyServiceTest extends TestCase
*/
public function keyIsProperlyCreatedWithExpirationDate()
{
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledOnce();
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce();
$date = Chronos::parse('2030-01-01');
$key = $this->service->create($date);
@ -61,7 +61,7 @@ class ApiKeyServiceTest extends TestCase
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
@ -76,7 +76,7 @@ class ApiKeyServiceTest extends TestCase
$key->disable();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
@ -90,7 +90,7 @@ class ApiKeyServiceTest extends TestCase
$key = new ApiKey(Chronos::now()->subDay());
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
@ -103,7 +103,7 @@ class ApiKeyServiceTest extends TestCase
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey())
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertTrue($this->service->check('12345'));
@ -117,7 +117,7 @@ class ApiKeyServiceTest extends TestCase
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->disable('12345');
@ -131,10 +131,10 @@ class ApiKeyServiceTest extends TestCase
$key = new ApiKey();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledOnce();
$this->assertTrue($key->isEnabled());
$returnedKey = $this->service->disable('12345');
@ -149,7 +149,7 @@ class ApiKeyServiceTest extends TestCase
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy([])->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys();
@ -162,7 +162,7 @@ class ApiKeyServiceTest extends TestCase
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy(['enabled' => true])->willReturn([])
->shouldBeCalledTimes(1);
->shouldBeCalledOnce();
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys(true);