LocateVisitsCommandTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. declare(strict_types=1);
  3. namespace ShlinkioTest\Shlink\CLI\Command\Visit;
  4. use PHPUnit\Framework\TestCase;
  5. use Prophecy\Argument;
  6. use Prophecy\PhpUnit\ProphecyTrait;
  7. use Prophecy\Prophecy\ObjectProphecy;
  8. use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
  9. use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
  10. use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
  11. use Shlinkio\Shlink\Common\Util\IpAddress;
  12. use Shlinkio\Shlink\Core\Entity\ShortUrl;
  13. use Shlinkio\Shlink\Core\Entity\Visit;
  14. use Shlinkio\Shlink\Core\Entity\VisitLocation;
  15. use Shlinkio\Shlink\Core\Model\Visitor;
  16. use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
  17. use Shlinkio\Shlink\Core\Visit\VisitLocator;
  18. use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
  19. use Shlinkio\Shlink\IpGeolocation\Model\Location;
  20. use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
  21. use Symfony\Component\Console\Application;
  22. use Symfony\Component\Console\Exception\RuntimeException;
  23. use Symfony\Component\Console\Output\OutputInterface;
  24. use Symfony\Component\Console\Tester\CommandTester;
  25. use Symfony\Component\Lock;
  26. use function sprintf;
  27. use const PHP_EOL;
  28. class LocateVisitsCommandTest extends TestCase
  29. {
  30. use ProphecyTrait;
  31. private CommandTester $commandTester;
  32. private ObjectProphecy $visitService;
  33. private ObjectProphecy $ipResolver;
  34. private ObjectProphecy $lock;
  35. private ObjectProphecy $dbUpdater;
  36. public function setUp(): void
  37. {
  38. $this->visitService = $this->prophesize(VisitLocator::class);
  39. $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
  40. $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
  41. $locker = $this->prophesize(Lock\LockFactory::class);
  42. $this->lock = $this->prophesize(Lock\LockInterface::class);
  43. $this->lock->acquire(false)->willReturn(true);
  44. $this->lock->release()->will(function (): void {
  45. });
  46. $locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal());
  47. $command = new LocateVisitsCommand(
  48. $this->visitService->reveal(),
  49. $this->ipResolver->reveal(),
  50. $locker->reveal(),
  51. $this->dbUpdater->reveal(),
  52. );
  53. $app = new Application();
  54. $app->add($command);
  55. $this->commandTester = new CommandTester($command);
  56. }
  57. /**
  58. * @test
  59. * @dataProvider provideArgs
  60. */
  61. public function expectedSetOfVisitsIsProcessedBasedOnArgs(
  62. int $expectedUnlocatedCalls,
  63. int $expectedEmptyCalls,
  64. int $expectedAllCalls,
  65. bool $expectWarningPrint,
  66. array $args
  67. ): void {
  68. $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
  69. $location = new VisitLocation(Location::emptyInstance());
  70. $mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
  71. $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
  72. $locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
  73. $mockMethodBehavior,
  74. );
  75. $locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
  76. $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
  77. Location::emptyInstance(),
  78. );
  79. $this->commandTester->setInputs(['y']);
  80. $this->commandTester->execute($args);
  81. $output = $this->commandTester->getDisplay();
  82. self::assertStringContainsString('Processing IP 1.2.3.0', $output);
  83. if ($expectWarningPrint) {
  84. self::assertStringContainsString('Continue at your own', $output);
  85. } else {
  86. self::assertStringNotContainsString('Continue at your own', $output);
  87. }
  88. $locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
  89. $locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
  90. $locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
  91. $resolveIpLocation->shouldHaveBeenCalledTimes(
  92. $expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
  93. );
  94. }
  95. public function provideArgs(): iterable
  96. {
  97. yield 'no args' => [1, 0, 0, false, []];
  98. yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
  99. yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
  100. }
  101. /**
  102. * @test
  103. * @dataProvider provideIgnoredAddresses
  104. */
  105. public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
  106. {
  107. $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
  108. $location = new VisitLocation(Location::emptyInstance());
  109. $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
  110. $this->invokeHelperMethods($visit, $location),
  111. );
  112. $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
  113. Location::emptyInstance(),
  114. );
  115. $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
  116. $output = $this->commandTester->getDisplay();
  117. self::assertStringContainsString($message, $output);
  118. if (empty($address)) {
  119. self::assertStringNotContainsString('Processing IP', $output);
  120. } else {
  121. self::assertStringContainsString('Processing IP', $output);
  122. }
  123. $locateVisits->shouldHaveBeenCalledOnce();
  124. $resolveIpLocation->shouldNotHaveBeenCalled();
  125. }
  126. public function provideIgnoredAddresses(): iterable
  127. {
  128. yield 'with empty address' => ['', 'Ignored visit with no IP address'];
  129. yield 'with null address' => [null, 'Ignored visit with no IP address'];
  130. yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
  131. }
  132. /** @test */
  133. public function errorWhileLocatingIpIsDisplayed(): void
  134. {
  135. $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
  136. $location = new VisitLocation(Location::emptyInstance());
  137. $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
  138. $this->invokeHelperMethods($visit, $location),
  139. );
  140. $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
  141. $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
  142. $output = $this->commandTester->getDisplay();
  143. self::assertStringContainsString('An error occurred while locating IP. Skipped', $output);
  144. $locateVisits->shouldHaveBeenCalledOnce();
  145. $resolveIpLocation->shouldHaveBeenCalledOnce();
  146. }
  147. private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
  148. {
  149. return function (array $args) use ($visit, $location): void {
  150. /** @var VisitGeolocationHelperInterface $helper */
  151. [$helper] = $args;
  152. $helper->geolocateVisit($visit);
  153. $helper->onVisitLocated($location, $visit);
  154. };
  155. }
  156. /** @test */
  157. public function noActionIsPerformedIfLockIsAcquired(): void
  158. {
  159. $this->lock->acquire(false)->willReturn(false);
  160. $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
  161. });
  162. $resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
  163. $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
  164. $output = $this->commandTester->getDisplay();
  165. self::assertStringContainsString(
  166. sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
  167. $output,
  168. );
  169. $locateVisits->shouldNotHaveBeenCalled();
  170. $resolveIpLocation->shouldNotHaveBeenCalled();
  171. }
  172. /**
  173. * @test
  174. * @dataProvider provideParams
  175. */
  176. public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void
  177. {
  178. $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void {
  179. });
  180. $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will(
  181. function (array $args) use ($olderDbExists): void {
  182. [$mustBeUpdated, $handleProgress] = $args;
  183. $mustBeUpdated($olderDbExists);
  184. $handleProgress(100, 50);
  185. throw $olderDbExists
  186. ? GeolocationDbUpdateFailedException::withOlderDb()
  187. : GeolocationDbUpdateFailedException::withoutOlderDb();
  188. },
  189. );
  190. $this->commandTester->execute([]);
  191. $output = $this->commandTester->getDisplay();
  192. self::assertStringContainsString(
  193. sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
  194. $output,
  195. );
  196. self::assertStringContainsString($expectedMessage, $output);
  197. $locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
  198. $checkDbUpdate->shouldHaveBeenCalledOnce();
  199. }
  200. public function provideParams(): iterable
  201. {
  202. yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
  203. yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
  204. }
  205. /** @test */
  206. public function providingAllFlagOnItsOwnDisplaysNotice(): void
  207. {
  208. $this->commandTester->execute(['--all' => true]);
  209. $output = $this->commandTester->getDisplay();
  210. self::assertStringContainsString('The --all flag has no effect on its own', $output);
  211. }
  212. /**
  213. * @test
  214. * @dataProvider provideAbortInputs
  215. */
  216. public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
  217. {
  218. $this->expectException(RuntimeException::class);
  219. $this->expectExceptionMessage('Execution aborted');
  220. $this->commandTester->setInputs($inputs);
  221. $this->commandTester->execute(['--all' => true, '--retry' => true]);
  222. }
  223. public function provideAbortInputs(): iterable
  224. {
  225. yield 'n' => [['n']];
  226. yield 'no' => [['no']];
  227. yield 'default' => [[PHP_EOL]];
  228. }
  229. }