Merge pull request #1065 from acelaya-forks/feature/split-db-update-and-location

Feature/split db update and location
This commit is contained in:
Alejandro Celaya 2021-04-08 17:13:29 +02:00 committed by GitHub
commit f30e922074
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 642 additions and 362 deletions

View File

@ -5,7 +5,7 @@ data/log/*
data/locks/* data/locks/*
data/proxies/* data/proxies/*
data/migrations_template.txt data/migrations_template.txt
data/GeoLite2-City.* data/GeoLite2-City*
data/database.sqlite data/database.sqlite
data/shlink-tests.db data/shlink-tests.db
CHANGELOG.md CHANGELOG.md

3
.gitignore vendored
View File

@ -6,8 +6,7 @@ composer.phar
vendor/ vendor/
data/database.sqlite data/database.sqlite
data/shlink-tests.db data/shlink-tests.db
data/GeoLite2-City.mmdb data/GeoLite2-City.*
data/GeoLite2-City.mmdb.*
docs/swagger-ui* docs/swagger-ui*
docs/mercure.html docs/mercure.html
docker-compose.override.yml docker-compose.override.yml

View File

@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased] ## [Unreleased]
### Added ### Added
* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which help identifying them when the list grows. * [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows.
* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole.
The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update.
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
### Changed ### Changed
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. * [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.

View File

@ -50,7 +50,7 @@
"shlinkio/shlink-config": "^1.0", "shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.2", "shlinkio/shlink-importer": "^2.2",
"shlinkio/shlink-installer": "^5.4", "shlinkio/shlink-installer": "dev-develop#aa50ea9 as 5.5",
"shlinkio/shlink-ip-geolocation": "^1.5", "shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1", "symfony/console": "^5.1",
"symfony/filesystem": "^5.1", "symfony/filesystem": "^5.1",

View File

@ -2,7 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Installer\Config\Option; use Shlinkio\Shlink\Installer\Config\Option;
use Shlinkio\Shlink\Installer\Util\InstallationCommand;
return [ return [
@ -45,11 +48,14 @@ return [
], ],
'installation_commands' => [ 'installation_commands' => [
'db_create_schema' => [ InstallationCommand::DB_CREATE_SCHEMA => [
'command' => 'bin/cli db:create', 'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME,
], ],
'db_migrate' => [ InstallationCommand::DB_MIGRATE => [
'command' => 'bin/cli db:migrate', 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME,
],
InstallationCommand::GEOLITE_DOWNLOAD_DB => [
'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME,
], ],
], ],
], ],

View File

@ -170,7 +170,7 @@ return [
], ],
'geolite2' => [ 'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove the default value
], ],
'mercure' => $helper->getMercureConfig(), 'mercure' => $helper->getMercureConfig(),

View File

@ -15,6 +15,12 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
echo "Clearing entities cache..." echo "Clearing entities cache..."
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
# Try to download GeoLite2 db file only if the license key env var was defined
if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
echo "Downloading GeoLite2 db file..."
php bin/cli visit:download-db -n -q
fi
# When restarting the container, swoole might think it is already in execution # When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0 # This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done

View File

@ -15,6 +15,7 @@ return [
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,

View File

@ -44,6 +44,7 @@ return [
Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
@ -80,11 +81,11 @@ return [
Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [ Command\Visit\LocateVisitsCommand::class => [
Visit\VisitLocator::class, Visit\VisitLocator::class,
IpLocationResolverInterface::class, IpLocationResolverInterface::class,
LockFactory::class, LockFactory::class,
Util\GeolocationDbUpdater::class,
], ],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
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 function sprintf;
class DownloadGeoLiteDbCommand extends Command
{
public const NAME = 'visit:download-db';
private GeolocationDbUpdaterInterface $dbUpdater;
private ?ProgressBar $progressBar = null;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater)
{
parent::__construct();
$this->dbUpdater = $dbUpdater;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription(
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
. 'copy if so.',
);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
$this->progressBar = new ProgressBar($io);
}, function (int $total, int $downloaded): void {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
});
if ($this->progressBar === null) {
$io->info('GeoLite2 db file is up to date.');
} else {
$this->progressBar->finish();
$io->success('GeoLite2 db file properly downloaded.');
}
return ExitCodes::EXIT_SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
$olderDbExists = $e->olderDbExists();
if ($olderDbExists) {
$io->warning(
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
);
} else {
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
}
if ($io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
}
}
}

View File

@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Entity\VisitLocation;
@ -19,7 +17,6 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -35,28 +32,26 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
private VisitLocatorInterface $visitLocator; private VisitLocatorInterface $visitLocator;
private IpLocationResolverInterface $ipLocationResolver; private IpLocationResolverInterface $ipLocationResolver;
private GeolocationDbUpdaterInterface $dbUpdater;
private SymfonyStyle $io; private SymfonyStyle $io;
private ?ProgressBar $progressBar = null;
public function __construct( public function __construct(
VisitLocatorInterface $visitLocator, VisitLocatorInterface $visitLocator,
IpLocationResolverInterface $ipLocationResolver, IpLocationResolverInterface $ipLocationResolver,
LockFactory $locker, LockFactory $locker
GeolocationDbUpdaterInterface $dbUpdater
) { ) {
parent::__construct($locker); parent::__construct($locker);
$this->visitLocator = $visitLocator; $this->visitLocator = $visitLocator;
$this->ipLocationResolver = $ipLocationResolver; $this->ipLocationResolver = $ipLocationResolver;
$this->dbUpdater = $dbUpdater;
} }
protected function configure(): void protected function configure(): void
{ {
$this $this
->setName(self::NAME) ->setName(self::NAME)
->setDescription('Resolves visits origin locations.') ->setDescription(
'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.',
)
->addOption( ->addOption(
'retry', 'retry',
'r', 'r',
@ -90,12 +85,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
); );
} }
if ($all && $retry && ! $this->warnAndVerifyContinue()) { if ($all && $retry && ! $this->warnAndVerifyContinue($input)) {
throw new RuntimeException('Execution aborted'); throw new RuntimeException('Execution aborted');
} }
} }
private function warnAndVerifyContinue(): bool private function warnAndVerifyContinue(InputInterface $input): bool
{ {
$this->io->warning([ $this->io->warning([
'You are about to process the location of all existing visits your short URLs received.', 'You are about to process the location of all existing visits your short URLs received.',
@ -113,7 +108,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$all = $retry && $input->getOption('all'); $all = $retry && $input->getOption('all');
try { try {
$this->checkDbUpdate(); $this->checkDbUpdate($input);
if ($all) { if ($all) {
$this->visitLocator->locateAllVisits($this); $this->visitLocator->locateAllVisits($this);
@ -128,7 +123,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
return ExitCodes::EXIT_SUCCESS; return ExitCodes::EXIT_SUCCESS;
} catch (Throwable $e) { } catch (Throwable $e) {
$this->io->error($e->getMessage()); $this->io->error($e->getMessage());
if ($e instanceof Throwable && $this->io->isVerbose()) { if ($this->io->isVerbose()) {
$this->getApplication()->renderThrowable($e, $this->io); $this->getApplication()->renderThrowable($e, $this->io);
} }
@ -176,33 +171,13 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$this->io->writeln($message); $this->io->writeln($message);
} }
private function checkDbUpdate(): void private function checkDbUpdate(InputInterface $input): void
{ {
try { $downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME);
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void { $exitCode = $downloadDbCommand->run($input, $this->io);
$this->io->writeln(
sprintf('<fg=blue>%s GeoLite2 database...</>', $olderDbExists ? 'Updating' : 'Downloading'),
);
$this->progressBar = new ProgressBar($this->io);
}, function (int $total, int $downloaded): void {
$this->progressBar->setMaxSteps($total);
$this->progressBar->setProgress($downloaded);
});
if ($this->progressBar !== null) { if ($exitCode === ExitCodes::EXIT_FAILURE) {
$this->progressBar->finish(); throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
$this->io->newLine();
}
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->io->error('GeoLite2 database download failed. It is not possible to locate visits.');
throw $e;
}
$this->io->newLine();
$this->io->writeln(
'<fg=yellow;options=bold>[Warning] GeoLite2 database update failed. Proceeding with old version.</>',
);
} }
} }

View File

@ -13,6 +13,11 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
{ {
private bool $olderDbExists; private bool $olderDbExists;
private function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public static function withOlderDb(?Throwable $prev = null): self public static function withOlderDb(?Throwable $prev = null): self
{ {
$e = new self( $e = new self(

View File

@ -32,13 +32,13 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/** /**
* @throws GeolocationDbUpdateFailedException * @throws GeolocationDbUpdateFailedException
*/ */
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void
{ {
$lock = $this->locker->createLock(self::LOCK_NAME); $lock = $this->locker->createLock(self::LOCK_NAME);
$lock->acquire(true); // Block until lock is released $lock->acquire(true); // Block until lock is released
try { try {
$this->downloadIfNeeded($mustBeUpdated, $handleProgress); $this->downloadIfNeeded($beforeDownload, $handleProgress);
} finally { } finally {
$lock->release(); $lock->release();
} }
@ -47,34 +47,16 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
/** /**
* @throws GeolocationDbUpdateFailedException * @throws GeolocationDbUpdateFailedException
*/ */
private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void
{ {
if (! $this->dbUpdater->databaseFileExists()) { if (! $this->dbUpdater->databaseFileExists()) {
$this->downloadNewDb(false, $mustBeUpdated, $handleProgress); $this->downloadNewDb(false, $beforeDownload, $handleProgress);
return; return;
} }
$meta = $this->geoLiteDbReader->metadata(); $meta = $this->geoLiteDbReader->metadata();
if ($this->buildIsTooOld($meta)) { if ($this->buildIsTooOld($meta)) {
$this->downloadNewDb(true, $mustBeUpdated, $handleProgress); $this->downloadNewDb(true, $beforeDownload, $handleProgress);
}
}
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(bool $olderDbExists, ?callable $mustBeUpdated, ?callable $handleProgress): void
{
if ($mustBeUpdated !== null) {
$mustBeUpdated($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($handleProgress);
} catch (RuntimeException $e) {
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
} }
} }
@ -105,4 +87,31 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch); throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
} }
/**
* @throws GeolocationDbUpdateFailedException
*/
private function downloadNewDb(bool $olderDbExists, ?callable $beforeDownload, ?callable $handleProgress): void
{
if ($beforeDownload !== null) {
$beforeDownload($olderDbExists);
}
try {
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
} catch (RuntimeException $e) {
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)
: GeolocationDbUpdateFailedException::withoutOlderDb($e);
}
}
private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable
{
if ($handleProgress === null) {
return null;
}
return fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists);
}
} }

View File

@ -11,5 +11,5 @@ interface GeolocationDbUpdaterInterface
/** /**
* @throws GeolocationDbUpdateFailedException * @throws GeolocationDbUpdateFailedException
*/ */
public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void; public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void;
} }

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
trait CliTestUtilsTrait
{
use ProphecyTrait;
/**
* @return ObjectProphecy|Command
*/
private function createCommandMock(string $name): ObjectProphecy
{
$command = $this->prophesize(Command::class);
$command->getName()->willReturn($name);
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
});
return $command;
}
private function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
{
$app = new Application();
$app->add($mainCommand);
foreach ($extraCommands as $command) {
$app->add($command);
}
return new CommandTester($mainCommand);
}
}

View File

@ -5,17 +5,16 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api; namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class DisableKeyCommandTest extends TestCase class DisableKeyCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $apiKeyService; private ObjectProphecy $apiKeyService;
@ -23,10 +22,7 @@ class DisableKeyCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$command = new DisableKeyCommand($this->apiKeyService->reveal()); $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal()));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -7,34 +7,30 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase class GenerateKeyCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $apiKeyService; private ObjectProphecy $apiKeyService;
private ObjectProphecy $roleResolver;
public function setUp(): void public function setUp(): void
{ {
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->roleResolver = $this->prophesize(RoleResolverInterface::class); $roleResolver = $this->prophesize(RoleResolverInterface::class);
$this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); $roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal()); $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal());
$app = new Application(); $this->commandTester = $this->testerForCommand($command);
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api; namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
@ -13,12 +12,12 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class ListKeysCommandTest extends TestCase class ListKeysCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $apiKeyService; private ObjectProphecy $apiKeyService;
@ -26,10 +25,7 @@ class ListKeysCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$command = new ListKeysCommand($this->apiKeyService->reveal()); $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal()));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** /**

View File

@ -9,11 +9,10 @@ use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockFactory;
@ -22,7 +21,7 @@ use Symfony\Component\Process\PhpExecutableFinder;
class CreateDatabaseCommandTest extends TestCase class CreateDatabaseCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $processHelper; private ObjectProphecy $processHelper;
@ -59,10 +58,8 @@ class CreateDatabaseCommandTest extends TestCase
$this->regularConn->reveal(), $this->regularConn->reveal(),
$noDbNameConn->reveal(), $noDbNameConn->reveal(),
); );
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ /** @test */

View File

@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockFactory;
@ -19,7 +18,7 @@ use Symfony\Component\Process\PhpExecutableFinder;
class MigrateDatabaseCommandTest extends TestCase class MigrateDatabaseCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $processHelper; private ObjectProphecy $processHelper;
@ -43,10 +42,7 @@ class MigrateDatabaseCommandTest extends TestCase
$this->processHelper->reveal(), $this->processHelper->reveal(),
$phpExecutableFinder->reveal(), $phpExecutableFinder->reveal(),
); );
$app = new Application(); $this->commandTester = $this->testerForCommand($command);
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,18 +5,17 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain; namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class ListDomainsCommandTest extends TestCase class ListDomainsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $domainService; private ObjectProphecy $domainService;
@ -24,12 +23,7 @@ class ListDomainsCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->domainService = $this->prophesize(DomainServiceInterface::class); $this->domainService = $this->prophesize(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal()));
$command = new ListDomainsCommand($this->domainService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -6,13 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use function array_pop; use function array_pop;
@ -22,7 +21,7 @@ use const PHP_EOL;
class DeleteShortUrlCommandTest extends TestCase class DeleteShortUrlCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $service; private ObjectProphecy $service;
@ -30,12 +29,7 @@ class DeleteShortUrlCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal()));
$command = new DeleteShortUrlCommand($this->service->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
@ -17,12 +16,12 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class GenerateShortUrlCommandTest extends TestCase class GenerateShortUrlCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $urlShortener; private ObjectProphecy $urlShortener;
@ -35,9 +34,7 @@ class GenerateShortUrlCommandTest extends TestCase
$this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5); $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5);
$app = new Application(); $this->commandTester = $this->testerForCommand($command);
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -8,7 +8,6 @@ use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
@ -21,14 +20,14 @@ use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use function sprintf; use function sprintf;
class GetVisitsCommandTest extends TestCase class GetVisitsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $visitsHelper; private ObjectProphecy $visitsHelper;
@ -37,9 +36,7 @@ class GetVisitsCommandTest extends TestCase
{ {
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetVisitsCommand($this->visitsHelper->reveal()); $command = new GetVisitsCommand($this->visitsHelper->reveal());
$app = new Application(); $this->commandTester = $this->testerForCommand($command);
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -8,7 +8,6 @@ use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
@ -17,14 +16,14 @@ use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use function explode; use function explode;
class ListShortUrlsCommandTest extends TestCase class ListShortUrlsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $shortUrlService; private ObjectProphecy $shortUrlService;
@ -32,12 +31,10 @@ class ListShortUrlsCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(
new ShortUrlStringifier([]), new ShortUrlStringifier([]),
)); ));
$app->add($command); $this->commandTester = $this->testerForCommand($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,14 +5,13 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use function sprintf; use function sprintf;
@ -21,7 +20,7 @@ use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase class ResolveUrlCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $urlResolver; private ObjectProphecy $urlResolver;
@ -29,11 +28,7 @@ class ResolveUrlCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$command = new ResolveUrlCommand($this->urlResolver->reveal()); $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal()));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -6,16 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class CreateTagCommandTest extends TestCase class CreateTagCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $tagService; private ObjectProphecy $tagService;
@ -23,12 +22,7 @@ class CreateTagCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->tagService = $this->prophesize(TagServiceInterface::class); $this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new CreateTagCommand($this->tagService->reveal()));
$command = new CreateTagCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,16 +5,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class DeleteTagsCommandTest extends TestCase class DeleteTagsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $tagService; private ObjectProphecy $tagService;
@ -22,12 +21,7 @@ class DeleteTagsCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->tagService = $this->prophesize(TagServiceInterface::class); $this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal()));
$command = new DeleteTagsCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,18 +5,17 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class ListTagsCommandTest extends TestCase class ListTagsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $tagService; private ObjectProphecy $tagService;
@ -24,12 +23,7 @@ class ListTagsCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->tagService = $this->prophesize(TagServiceInterface::class); $this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal()));
$command = new ListTagsCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -5,19 +5,18 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class RenameTagCommandTest extends TestCase class RenameTagCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $tagService; private ObjectProphecy $tagService;
@ -25,12 +24,7 @@ class RenameTagCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->tagService = $this->prophesize(TagServiceInterface::class); $this->tagService = $this->prophesize(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal()));
$command = new RenameTagCommand($this->tagService->reveal());
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
} }
/** @test */ /** @test */

View File

@ -0,0 +1,107 @@
<?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\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
class DownloadGeoLiteDbCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private ObjectProphecy $dbUpdater;
protected function setUp(): void
{
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater->reveal()));
}
/**
* @test
* @dataProvider provideFailureParams
*/
public function showsProperMessageWhenGeoLiteUpdateFails(
bool $olderDbExists,
string $expectedMessage,
int $expectedExitCode
): void {
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists): void {
[$beforeDownload, $handleProgress] = $args;
$beforeDownload($olderDbExists);
$handleProgress(100, 50);
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb()
: GeolocationDbUpdateFailedException::withoutOlderDb();
},
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString(
sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'),
$output,
);
self::assertStringContainsString($expectedMessage, $output);
self::assertSame($expectedExitCode, $exitCode);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideFailureParams(): iterable
{
yield 'existing db' => [
true,
'[WARNING] GeoLite2 db file update failed. Visits will continue to be located',
ExitCodes::EXIT_WARNING,
];
yield 'not existing db' => [
false,
'[ERROR] GeoLite2 db file download failed. It will not be possible to locate',
ExitCodes::EXIT_FAILURE,
];
}
/**
* @test
* @dataProvider provideSuccessParams
*/
public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void
{
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will($checkUpdateBehavior);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString($expectedMessage, $output);
self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideSuccessParams(): iterable
{
yield 'up to date db' => [function (): void {
}, '[INFO] GeoLite2 db file is up to date.'];
yield 'outdated db' => [function (array $args): void {
[$beforeDownload] = $args;
$beforeDownload(true);
}, '[OK] GeoLite2 db file properly downloaded.'];
}
}

View File

@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
@ -21,7 +20,7 @@ use Shlinkio\Shlink\Core\Visit\VisitLocator;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@ -33,19 +32,18 @@ use const PHP_EOL;
class LocateVisitsCommandTest extends TestCase class LocateVisitsCommandTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $visitService; private ObjectProphecy $visitService;
private ObjectProphecy $ipResolver; private ObjectProphecy $ipResolver;
private ObjectProphecy $lock; private ObjectProphecy $lock;
private ObjectProphecy $dbUpdater; private ObjectProphecy $downloadDbCommand;
public function setUp(): void public function setUp(): void
{ {
$this->visitService = $this->prophesize(VisitLocator::class); $this->visitService = $this->prophesize(VisitLocator::class);
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$locker = $this->prophesize(Lock\LockFactory::class); $locker = $this->prophesize(Lock\LockFactory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class); $this->lock = $this->prophesize(Lock\LockInterface::class);
@ -58,12 +56,12 @@ class LocateVisitsCommandTest extends TestCase
$this->visitService->reveal(), $this->visitService->reveal(),
$this->ipResolver->reveal(), $this->ipResolver->reveal(),
$locker->reveal(), $locker->reveal(),
$this->dbUpdater->reveal(),
); );
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command); $this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME);
$this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_SUCCESS);
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand->reveal());
} }
/** /**
@ -202,43 +200,16 @@ class LocateVisitsCommandTest extends TestCase
$resolveIpLocation->shouldNotHaveBeenCalled(); $resolveIpLocation->shouldNotHaveBeenCalled();
} }
/** /** @test */
* @test public function showsProperMessageWhenGeoLiteUpdateFails(): void
* @dataProvider provideParams
*/
public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
{ {
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void { $this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE);
});
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($olderDbExists): void {
[$mustBeUpdated, $handleProgress] = $args;
$mustBeUpdated($olderDbExists);
$handleProgress(100, 50);
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb()
: GeolocationDbUpdateFailedException::withoutOlderDb();
},
);
$this->commandTester->execute([]); $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertStringContainsString( self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output);
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'), $this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled();
$output,
);
self::assertStringContainsString($expectedMessage, $output);
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
public function provideParams(): iterable
{
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
} }
/** @test */ /** @test */

View File

@ -6,17 +6,13 @@ namespace ShlinkioTest\Shlink\CLI\Factory;
use Laminas\ServiceManager\ServiceManager; use Laminas\ServiceManager\ServiceManager;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Command\Command;
class ApplicationFactoryTest extends TestCase class ApplicationFactoryTest extends TestCase
{ {
use ProphecyTrait; use CliTestUtilsTrait;
private ApplicationFactory $factory; private ApplicationFactory $factory;
@ -54,17 +50,4 @@ class ApplicationFactoryTest extends TestCase
AppOptions::class => new AppOptions(), AppOptions::class => new AppOptions(),
]]); ]]);
} }
private function createCommandMock(string $name): ObjectProphecy
{
$command = $this->prophesize(Command::class);
$command->getName()->willReturn($name);
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
});
return $command;
}
} }

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\Hub;
@ -14,14 +15,15 @@ return [
'events' => [ 'events' => [
'regular' => [ 'regular' => [
EventDispatcher\Event\VisitLocated::class => [ EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\NotifyVisitToMercure::class, EventDispatcher\LocateVisit::class,
EventDispatcher\NotifyVisitToWebHooks::class,
], ],
], ],
'async' => [ 'async' => [
EventDispatcher\Event\UrlVisited::class => [ EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\LocateVisit::class, EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
], ],
], ],
], ],
@ -31,10 +33,14 @@ return [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
], ],
'delegators' => [ 'delegators' => [
EventDispatcher\LocateVisit::class => [ EventDispatcher\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class, EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
], ],
], ],
@ -45,7 +51,7 @@ return [
IpLocationResolverInterface::class, IpLocationResolverInterface::class,
'em', 'em',
'Logger_Shlink', 'Logger_Shlink',
GeolocationDbUpdater::class, DbUpdater::class,
EventDispatcherInterface::class, EventDispatcherInterface::class,
], ],
EventDispatcher\NotifyVisitToWebHooks::class => [ EventDispatcher\NotifyVisitToWebHooks::class => [
@ -62,6 +68,7 @@ return [
'em', 'em',
'Logger_Shlink', 'Logger_Shlink',
], ],
EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'],
], ],
]; ];

View File

@ -7,31 +7,29 @@ namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Throwable;
use function sprintf;
class LocateVisit class LocateVisit
{ {
private IpLocationResolverInterface $ipLocationResolver; private IpLocationResolverInterface $ipLocationResolver;
private EntityManagerInterface $em; private EntityManagerInterface $em;
private LoggerInterface $logger; private LoggerInterface $logger;
private GeolocationDbUpdaterInterface $dbUpdater; private DbUpdaterInterface $dbUpdater;
private EventDispatcherInterface $eventDispatcher; private EventDispatcherInterface $eventDispatcher;
public function __construct( public function __construct(
IpLocationResolverInterface $ipLocationResolver, IpLocationResolverInterface $ipLocationResolver,
EntityManagerInterface $em, EntityManagerInterface $em,
LoggerInterface $logger, LoggerInterface $logger,
GeolocationDbUpdaterInterface $dbUpdater, DbUpdaterInterface $dbUpdater,
EventDispatcherInterface $eventDispatcher EventDispatcherInterface $eventDispatcher
) { ) {
$this->ipLocationResolver = $ipLocationResolver; $this->ipLocationResolver = $ipLocationResolver;
@ -54,36 +52,19 @@ class LocateVisit
return; return;
} }
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
}
$this->eventDispatcher->dispatch(new VisitLocated($visitId)); $this->eventDispatcher->dispatch(new VisitLocated($visitId));
} }
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
{
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void {
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
});
} catch (GeolocationDbUpdateFailedException $e) {
if (! $e->olderDbExists()) {
$this->logger->error(
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
['e' => $e, 'visitId' => $visitId],
);
return false;
}
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
}
return true;
}
private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void
{ {
if (! $this->dbUpdater->databaseFileExists()) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [
'visitId' => $visitId,
]);
return;
}
$isLocatable = $originalIpAddress !== null || $visit->isLocatable(); $isLocatable = $originalIpAddress !== null || $visit->isLocatable();
$addr = $originalIpAddress ?? $visit->getRemoteAddr(); $addr = $originalIpAddress ?? $visit->getRemoteAddr();
@ -97,6 +78,11 @@ class LocateVisit
'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
['e' => $e, 'visitId' => $visitId], ['e' => $e, 'visitId' => $visitId],
); );
} catch (Throwable $e) {
$this->logger->error(
'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}',
['e' => $e, 'visitId' => $visitId],
);
} }
} }
} }

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Throwable;
use function sprintf;
class UpdateGeoLiteDb
{
private GeolocationDbUpdaterInterface $dbUpdater;
private LoggerInterface $logger;
public function __construct(GeolocationDbUpdaterInterface $dbUpdater, LoggerInterface $logger)
{
$this->dbUpdater = $dbUpdater;
$this->logger = $logger;
}
public function __invoke(): void
{
$beforeDownload = fn (bool $olderDbExists) => $this->logger->notice(
sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'),
);
$messageLogged = false;
$handleProgress = function (int $total, int $downloaded, bool $olderDbExists) use (&$messageLogged): void {
if ($messageLogged || $total > $downloaded) {
return;
}
$messageLogged = true;
$this->logger->notice(sprintf('Finished %s GeoLite2 db file', $olderDbExists ? 'updating' : 'downloading'));
};
try {
$this->dbUpdater->checkDbUpdate($beforeDownload, $handleProgress);
} catch (Throwable $e) {
$this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]);
}
}
}

View File

@ -65,9 +65,11 @@ class VisitsTracker implements VisitsTrackerInterface
private function trackVisit(Visit $visit, Visitor $visitor): void private function trackVisit(Visit $visit, Visitor $visitor): void
{ {
$this->em->transactional(function () use ($visit, $visitor): void {
$this->em->persist($visit); $this->em->persist($visit);
$this->em->flush(); $this->em->flush();
$this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress()));
});
} }
} }

View File

@ -5,14 +5,13 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher; namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use OutOfRangeException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
@ -22,6 +21,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit; use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@ -41,9 +41,11 @@ class LocateVisitTest extends TestCase
$this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->dbUpdater->databaseFileExists()->willReturn(true);
$this->locateVisit = new LocateVisit( $this->locateVisit = new LocateVisit(
$this->ipLocationResolver->reveal(), $this->ipLocationResolver->reveal(),
$this->em->reveal(), $this->em->reveal(),
@ -73,6 +75,31 @@ class LocateVisitTest extends TestCase
$dispatch->shouldNotHaveBeenCalled(); $dispatch->shouldNotHaveBeenCalled();
} }
/** @test */
public function nonExistingGeoLiteDbLogsWarning(): void
{
$event = new UrlVisited('123');
$findVisit = $this->em->find(Visit::class, '123')->willReturn(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
);
$dbExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
$logWarning = $this->logger->warning(
'Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.',
['visitId' => 123],
);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
});
($this->locateVisit)($event);
$findVisit->shouldHaveBeenCalledOnce();
$dbExists->shouldHaveBeenCalledOnce();
$this->em->flush()->shouldNotHaveBeenCalled();
$this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
$logWarning->shouldHaveBeenCalled();
$dispatch->shouldHaveBeenCalledOnce();
}
/** @test */ /** @test */
public function invalidAddressLogsWarning(): void public function invalidAddressLogsWarning(): void
{ {
@ -84,7 +111,7 @@ class LocateVisitTest extends TestCase
WrongIpException::class, WrongIpException::class,
); );
$logWarning = $this->logger->warning( $logWarning = $this->logger->warning(
Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'), 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
Argument::type('array'), Argument::type('array'),
); );
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
@ -99,6 +126,32 @@ class LocateVisitTest extends TestCase
$dispatch->shouldHaveBeenCalledOnce(); $dispatch->shouldHaveBeenCalledOnce();
} }
/** @test */
public function unhandledExceptionLogsError(): void
{
$event = new UrlVisited('123');
$findVisit = $this->em->find(Visit::class, '123')->willReturn(
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')),
);
$resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow(
OutOfRangeException::class,
);
$logError = $this->logger->error(
'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}',
Argument::type('array'),
);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
});
($this->locateVisit)($event);
$findVisit->shouldHaveBeenCalledOnce();
$resolveLocation->shouldHaveBeenCalledOnce();
$logError->shouldHaveBeenCalled();
$this->em->flush()->shouldNotHaveBeenCalled();
$dispatch->shouldHaveBeenCalledOnce();
}
/** /**
* @test * @test
* @dataProvider provideNonLocatableVisits * @dataProvider provideNonLocatableVisits
@ -173,67 +226,4 @@ class LocateVisitTest extends TestCase
yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4'];
} }
/** @test */
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
{
$e = GeolocationDbUpdateFailedException::withOlderDb();
$ipAddr = '1.2.3.0';
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, ''));
$location = new Location('', '', '', '', 0.0, 0.0, '');
$event = new UrlVisited('123');
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
$flush = $this->em->flush()->will(function (): void {
});
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
});
($this->locateVisit)($event);
self::assertEquals($visit->getVisitLocation(), new VisitLocation($location));
$findVisit->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce();
$resolveIp->shouldHaveBeenCalledOnce();
$checkUpdateDb->shouldHaveBeenCalledOnce();
$this->logger->warning(
'GeoLite2 database update failed. Proceeding with old version. {e}',
['e' => $e],
)->shouldHaveBeenCalledOnce();
$dispatch->shouldHaveBeenCalledOnce();
}
/** @test */
public function errorWhenDownloadingGeoLiteCancelsLocation(): void
{
$e = GeolocationDbUpdateFailedException::withoutOlderDb();
$ipAddr = '1.2.3.0';
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, ''));
$location = new Location('', '', '', '', 0.0, 0.0, '');
$event = new UrlVisited('123');
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
$flush = $this->em->flush()->will(function (): void {
});
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
$logError = $this->logger->error(
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
['e' => $e, 'visitId' => 123],
);
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void {
});
($this->locateVisit)($event);
self::assertNull($visit->getVisitLocation());
$findVisit->shouldHaveBeenCalledOnce();
$flush->shouldNotHaveBeenCalled();
$resolveIp->shouldNotHaveBeenCalled();
$checkUpdateDb->shouldHaveBeenCalledOnce();
$logError->shouldHaveBeenCalledOnce();
$dispatch->shouldHaveBeenCalledOnce();
}
} }

View File

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb;
class UpdateGeoLiteDbTest extends TestCase
{
use ProphecyTrait;
private UpdateGeoLiteDb $listener;
private ObjectProphecy $dbUpdater;
private ObjectProphecy $logger;
protected function setUp(): void
{
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class);
$this->listener = new UpdateGeoLiteDb($this->dbUpdater->reveal(), $this->logger->reveal());
}
/** @test */
public function exceptionWhileUpdatingDbLogsError(): void
{
$e = new RuntimeException();
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
$logError = $this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]);
($this->listener)();
$checkDbUpdate->shouldHaveBeenCalledOnce();
$logError->shouldHaveBeenCalledOnce();
$this->logger->notice(Argument::cetera())->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideFlags
*/
public function noticeMessageIsPrintedWhenFirstCallbackIsInvoked(bool $oldDbExists, string $expectedMessage): void
{
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($oldDbExists): void {
[$firstCallback] = $args;
$firstCallback($oldDbExists);
},
);
$logNotice = $this->logger->notice($expectedMessage);
($this->listener)();
$checkDbUpdate->shouldHaveBeenCalledOnce();
$logNotice->shouldHaveBeenCalledOnce();
$this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideFlags(): iterable
{
yield 'existing old db' => [true, 'Updating GeoLite2 db file...'];
yield 'not existing old db' => [false, 'Downloading GeoLite2 db file...'];
}
/**
* @test
* @dataProvider provideDownloaded
*/
public function noticeMessageIsPrintedWhenSecondCallbackIsInvoked(
int $total,
int $downloaded,
bool $oldDbExists,
?string $expectedMessage
): void {
$checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
function (array $args) use ($total, $downloaded, $oldDbExists): void {
[, $secondCallback] = $args;
// Invoke several times to ensure the log is printed only once
$secondCallback($total, $downloaded, $oldDbExists);
$secondCallback($total, $downloaded, $oldDbExists);
$secondCallback($total, $downloaded, $oldDbExists);
},
);
$logNotice = $this->logger->notice($expectedMessage ?? Argument::cetera());
($this->listener)();
if ($expectedMessage !== null) {
$logNotice->shouldHaveBeenCalledOnce();
} else {
$logNotice->shouldNotHaveBeenCalled();
}
$checkDbUpdate->shouldHaveBeenCalledOnce();
$this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideDownloaded(): iterable
{
yield [100, 0, true, null];
yield [100, 0, false, null];
yield [100, 99, true, null];
yield [100, 99, false, null];
yield [100, 100, true, 'Finished updating GeoLite2 db file'];
yield [100, 100, false, 'Finished downloading GeoLite2 db file'];
yield [100, 101, true, 'Finished updating GeoLite2 db file'];
yield [100, 101, false, 'Finished downloading GeoLite2 db file'];
}
}

View File

@ -29,6 +29,11 @@ class VisitsTrackerTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->em = $this->prophesize(EntityManager::class); $this->em = $this->prophesize(EntityManager::class);
$this->em->transactional(Argument::any())->will(function (array $args) {
[$callback] = $args;
return $callback();
});
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->options = new UrlShortenerOptions(); $this->options = new UrlShortenerOptions();
@ -41,11 +46,14 @@ class VisitsTrackerTest extends TestCase
*/ */
public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void
{ {
$this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce(); $persist = $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->will(function (): void {
$this->em->flush()->shouldBeCalledOnce(); });
$this->visitsTracker->{$method}(...$args); $this->visitsTracker->{$method}(...$args);
$persist->shouldHaveBeenCalledOnce();
$this->em->transactional(Argument::cetera())->shouldHaveBeenCalledOnce();
$this->em->flush()->shouldHaveBeenCalledOnce();
$this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled();
} }
@ -68,6 +76,7 @@ class VisitsTrackerTest extends TestCase
$this->visitsTracker->{$method}(Visitor::emptyInstance()); $this->visitsTracker->{$method}(Visitor::emptyInstance());
$this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->transactional(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->flush()->shouldNotHaveBeenCalled(); $this->em->flush()->shouldNotHaveBeenCalled();
} }