diff --git a/.dockerignore b/.dockerignore index f9102acb..9fb114c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,7 @@ data/log/* data/locks/* data/proxies/* data/migrations_template.txt -data/GeoLite2-City.* +data/GeoLite2-City* data/database.sqlite data/shlink-tests.db CHANGELOG.md diff --git a/.gitignore b/.gitignore index 03b2790e..32942a29 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ composer.phar vendor/ data/database.sqlite data/shlink-tests.db -data/GeoLite2-City.mmdb -data/GeoLite2-City.mmdb.* +data/GeoLite2-City.* docs/swagger-ui* docs/mercure.html docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cf925c..1c88f036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### 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 * [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. diff --git a/composer.json b/composer.json index bff515f0..41885c62 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.1", "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", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index d18f31f4..605b16ce 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -2,7 +2,10 @@ declare(strict_types=1); +namespace Shlinkio\Shlink\CLI; + use Shlinkio\Shlink\Installer\Config\Option; +use Shlinkio\Shlink\Installer\Util\InstallationCommand; return [ @@ -45,11 +48,14 @@ return [ ], 'installation_commands' => [ - 'db_create_schema' => [ - 'command' => 'bin/cli db:create', + InstallationCommand::DB_CREATE_SCHEMA => [ + 'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME, ], - 'db_migrate' => [ - 'command' => 'bin/cli db:migrate', + InstallationCommand::DB_MIGRATE => [ + 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME, + ], + InstallationCommand::GEOLITE_DOWNLOAD_DB => [ + 'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME, ], ], ], diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 4ddd52e5..d022f79d 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -170,7 +170,7 @@ return [ ], 'geolite2' => [ - 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), + 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove the default value ], 'mercure' => $helper->getMercureConfig(), diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index df480d2f..1f9337c4 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -15,6 +15,12 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q echo "Clearing entities cache..." 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 # 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 diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 6e32428a..6043833b 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -15,6 +15,7 @@ return [ Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::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\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 80b26b8d..7d7e2865 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -44,6 +44,7 @@ return [ Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, + Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, @@ -80,11 +81,11 @@ return [ Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], + Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ Visit\VisitLocator::class, IpLocationResolverInterface::class, LockFactory::class, - Util\GeolocationDbUpdater::class, ], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php new file mode 100644 index 00000000..3d76663a --- /dev/null +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -0,0 +1,80 @@ +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('%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; + } + } +} diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 67678d4d..0bcfb1d7 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; -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\Core\Entity\Visit; 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\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Exception\RuntimeException; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -35,28 +32,26 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat private VisitLocatorInterface $visitLocator; private IpLocationResolverInterface $ipLocationResolver; - private GeolocationDbUpdaterInterface $dbUpdater; private SymfonyStyle $io; - private ?ProgressBar $progressBar = null; public function __construct( VisitLocatorInterface $visitLocator, IpLocationResolverInterface $ipLocationResolver, - LockFactory $locker, - GeolocationDbUpdaterInterface $dbUpdater + LockFactory $locker ) { parent::__construct($locker); $this->visitLocator = $visitLocator; $this->ipLocationResolver = $ipLocationResolver; - $this->dbUpdater = $dbUpdater; } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Resolves visits origin locations.') + ->setDescription( + 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.', + ) ->addOption( 'retry', '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'); } } - private function warnAndVerifyContinue(): bool + private function warnAndVerifyContinue(InputInterface $input): bool { $this->io->warning([ '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'); try { - $this->checkDbUpdate(); + $this->checkDbUpdate($input); if ($all) { $this->visitLocator->locateAllVisits($this); @@ -128,7 +123,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat return ExitCodes::EXIT_SUCCESS; } catch (Throwable $e) { $this->io->error($e->getMessage()); - if ($e instanceof Throwable && $this->io->isVerbose()) { + if ($this->io->isVerbose()) { $this->getApplication()->renderThrowable($e, $this->io); } @@ -176,33 +171,13 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $this->io->writeln($message); } - private function checkDbUpdate(): void + private function checkDbUpdate(InputInterface $input): void { - try { - $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void { - $this->io->writeln( - sprintf('%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); - }); + $downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME); + $exitCode = $downloadDbCommand->run($input, $this->io); - if ($this->progressBar !== null) { - $this->progressBar->finish(); - $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( - '[Warning] GeoLite2 database update failed. Proceeding with old version.', - ); + if ($exitCode === ExitCodes::EXIT_FAILURE) { + throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index f663fd8f..07d66855 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,6 +13,11 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { 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 { $e = new self( diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index b8f5b756..6e7c2da2 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -32,13 +32,13 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @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->acquire(true); // Block until lock is released try { - $this->downloadIfNeeded($mustBeUpdated, $handleProgress); + $this->downloadIfNeeded($beforeDownload, $handleProgress); } finally { $lock->release(); } @@ -47,34 +47,16 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void + private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void { if (! $this->dbUpdater->databaseFileExists()) { - $this->downloadNewDb(false, $mustBeUpdated, $handleProgress); + $this->downloadNewDb(false, $beforeDownload, $handleProgress); return; } $meta = $this->geoLiteDbReader->metadata(); if ($this->buildIsTooOld($meta)) { - $this->downloadNewDb(true, $mustBeUpdated, $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); + $this->downloadNewDb(true, $beforeDownload, $handleProgress); } } @@ -105,4 +87,31 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface 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); + } } diff --git a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php b/module/CLI/src/Util/GeolocationDbUpdaterInterface.php index 1eda5123..714f6a11 100644 --- a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php +++ b/module/CLI/src/Util/GeolocationDbUpdaterInterface.php @@ -11,5 +11,5 @@ interface GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void; + public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void; } diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/CliTestUtilsTrait.php new file mode 100644 index 00000000..412131dc --- /dev/null +++ b/module/CLI/test/CliTestUtilsTrait.php @@ -0,0 +1,44 @@ +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); + } +} diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 49835f85..90942dc9 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -5,17 +5,16 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class DisableKeyCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -23,10 +22,7 @@ class DisableKeyCommandTest extends TestCase public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $command = new DisableKeyCommand($this->apiKeyService->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 5c0c3c8a..e5c543d5 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -7,34 +7,30 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; 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\Tester\CommandTester; class GenerateKeyCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; - private ObjectProphecy $roleResolver; public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $this->roleResolver = $this->prophesize(RoleResolverInterface::class); - $this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); + $roleResolver = $this->prophesize(RoleResolverInterface::class); + $roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); - $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal()); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index fa6816d2..fc845ff7 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; 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\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListKeysCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -26,10 +25,7 @@ class ListKeysCommandTest extends TestCase public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $command = new ListKeysCommand($this->apiKeyService->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal())); } /** diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index db9dcf66..70d4d5eb 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -9,11 +9,10 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; 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\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -22,7 +21,7 @@ use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $processHelper; @@ -59,10 +58,8 @@ class CreateDatabaseCommandTest extends TestCase $this->regularConn->reveal(), $noDbNameConn->reveal(), ); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index d25f44f2..d301f55e 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; 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\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -19,7 +18,7 @@ use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $processHelper; @@ -43,10 +42,7 @@ class MigrateDatabaseCommandTest extends TestCase $this->processHelper->reveal(), $phpExecutableFinder->reveal(), ); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index a0f79448..04f7eb5d 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -5,18 +5,17 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Domain; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListDomainsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $domainService; @@ -24,12 +23,7 @@ class ListDomainsCommandTest extends TestCase public function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - - $command = new ListDomainsCommand($this->domainService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 83fd792d..a6b6fc78 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -6,13 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function array_pop; @@ -22,7 +21,7 @@ use const PHP_EOL; class DeleteShortUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $service; @@ -30,12 +29,7 @@ class DeleteShortUrlCommandTest extends TestCase public function setUp(): void { $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); - - $command = new DeleteShortUrlCommand($this->service->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 25953d38..19767dc7 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand; 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\Service\UrlShortener; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class GenerateShortUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $urlShortener; @@ -35,9 +34,7 @@ class GenerateShortUrlCommandTest extends TestCase $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index d25d5763..044866ed 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; 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\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; class GetVisitsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $visitsHelper; @@ -37,9 +36,7 @@ class GetVisitsCommandTest extends TestCase { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $command = new GetVisitsCommand($this->visitsHelper->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 3f2b38b1..08519b62 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; 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\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function explode; class ListShortUrlsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $shortUrlService; @@ -32,12 +31,10 @@ class ListShortUrlsCommandTest extends TestCase public function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $app = new Application(); $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index f0025b65..2a816207 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; @@ -21,7 +20,7 @@ use const PHP_EOL; class ResolveUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $urlResolver; @@ -29,11 +28,7 @@ class ResolveUrlCommandTest extends TestCase public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $command = new ResolveUrlCommand($this->urlResolver->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index 2789c481..7062cb45 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -6,16 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class CreateTagCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -23,12 +22,7 @@ class CreateTagCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new CreateTagCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new CreateTagCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 6d3737c1..46f61814 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -5,16 +5,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class DeleteTagsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -22,12 +21,7 @@ class DeleteTagsCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new DeleteTagsCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 5b9e14e9..9ec42e54 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -5,18 +5,17 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListTagsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -24,12 +23,7 @@ class ListTagsCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new ListTagsCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index d457c25d..3a52aba3 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -5,19 +5,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class RenameTagCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -25,12 +24,7 @@ class RenameTagCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new RenameTagCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php new file mode 100644 index 00000000..7ead517d --- /dev/null +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -0,0 +1,107 @@ +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.']; + } +} diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index d5ee2982..6e4213f6 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; -use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; 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\Model\Location; 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\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; @@ -33,19 +32,18 @@ use const PHP_EOL; class LocateVisitsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $visitService; private ObjectProphecy $ipResolver; private ObjectProphecy $lock; - private ObjectProphecy $dbUpdater; + private ObjectProphecy $downloadDbCommand; public function setUp(): void { $this->visitService = $this->prophesize(VisitLocator::class); $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); @@ -58,12 +56,12 @@ class LocateVisitsCommandTest extends TestCase $this->visitService->reveal(), $this->ipResolver->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(); } - /** - * @test - * @dataProvider provideParams - */ - public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void + /** @test */ + public function showsProperMessageWhenGeoLiteUpdateFails(): void { - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void { - }); - $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->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString( - sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'), - $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.']; + self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output); + $this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index ee0793bc..fbb5ace9 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -6,17 +6,13 @@ namespace ShlinkioTest\Shlink\CLI\Factory; use Laminas\ServiceManager\ServiceManager; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\Core\Options\AppOptions; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; class ApplicationFactoryTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private ApplicationFactory $factory; @@ -54,17 +50,4 @@ class ApplicationFactoryTest extends TestCase 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; - } } diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 5e1a6c9f..bddd59f5 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Mercure\Hub; @@ -14,14 +15,15 @@ return [ 'events' => [ 'regular' => [ - EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\NotifyVisitToMercure::class, - EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], ], 'async' => [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, + EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\NotifyVisitToMercure::class, + EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\UpdateGeoLiteDb::class, ], ], ], @@ -31,10 +33,14 @@ return [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, ], 'delegators' => [ - EventDispatcher\LocateVisit::class => [ + EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\NotifyVisitToWebHooks::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], ], @@ -45,7 +51,7 @@ return [ IpLocationResolverInterface::class, 'em', 'Logger_Shlink', - GeolocationDbUpdater::class, + DbUpdater::class, EventDispatcherInterface::class, ], EventDispatcher\NotifyVisitToWebHooks::class => [ @@ -62,6 +68,7 @@ return [ 'em', 'Logger_Shlink', ], + EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], ], ]; diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 32da6060..80dc18eb 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -7,31 +7,29 @@ namespace Shlinkio\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use Psr\EventDispatcher\EventDispatcherInterface; 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\VisitLocation; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; - -use function sprintf; +use Throwable; class LocateVisit { private IpLocationResolverInterface $ipLocationResolver; private EntityManagerInterface $em; private LoggerInterface $logger; - private GeolocationDbUpdaterInterface $dbUpdater; + private DbUpdaterInterface $dbUpdater; private EventDispatcherInterface $eventDispatcher; public function __construct( IpLocationResolverInterface $ipLocationResolver, EntityManagerInterface $em, LoggerInterface $logger, - GeolocationDbUpdaterInterface $dbUpdater, + DbUpdaterInterface $dbUpdater, EventDispatcherInterface $eventDispatcher ) { $this->ipLocationResolver = $ipLocationResolver; @@ -54,36 +52,19 @@ class LocateVisit return; } - if ($this->downloadOrUpdateGeoLiteDb($visitId)) { - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - } - + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); $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 { + 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(); $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}', ['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], + ); } } } diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php new file mode 100644 index 00000000..f17a7ffb --- /dev/null +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -0,0 +1,45 @@ +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]); + } + } +} diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 306da7a9..f8c82b49 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -65,9 +65,11 @@ class VisitsTracker implements VisitsTrackerInterface private function trackVisit(Visit $visit, Visitor $visitor): void { - $this->em->persist($visit); - $this->em->flush(); + $this->em->transactional(function () use ($visit, $visitor): void { + $this->em->persist($visit); + $this->em->flush(); - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + }); } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 081f0f86..fda45c58 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; +use OutOfRangeException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; 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\Core\Entity\ShortUrl; 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\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; @@ -41,9 +41,11 @@ class LocateVisitTest extends TestCase $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); + $this->dbUpdater->databaseFileExists()->willReturn(true); + $this->locateVisit = new LocateVisit( $this->ipLocationResolver->reveal(), $this->em->reveal(), @@ -73,6 +75,31 @@ class LocateVisitTest extends TestCase $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 */ public function invalidAddressLogsWarning(): void { @@ -84,7 +111,7 @@ class LocateVisitTest extends TestCase WrongIpException::class, ); $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'), ); $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { @@ -99,6 +126,32 @@ class LocateVisitTest extends TestCase $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 * @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 '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(); - } } diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php new file mode 100644 index 00000000..a492f9dd --- /dev/null +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -0,0 +1,118 @@ +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']; + } +} diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 118ebc06..bba4e919 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -29,6 +29,11 @@ class VisitsTrackerTest extends TestCase public function setUp(): void { $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->options = new UrlShortenerOptions(); @@ -41,11 +46,14 @@ class VisitsTrackerTest extends TestCase */ public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void { - $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce(); - $this->em->flush()->shouldBeCalledOnce(); + $persist = $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->will(function (): void { + }); $this->visitsTracker->{$method}(...$args); + $persist->shouldHaveBeenCalledOnce(); + $this->em->transactional(Argument::cetera())->shouldHaveBeenCalledOnce(); + $this->em->flush()->shouldHaveBeenCalledOnce(); $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); } @@ -68,6 +76,7 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker->{$method}(Visitor::emptyInstance()); $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->transactional(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->flush()->shouldNotHaveBeenCalled(); }