mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-25 18:45:27 -06:00
Defined stricter model to represent one geo location
This commit is contained in:
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
|
|||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
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;
|
||||||
@@ -75,7 +76,7 @@ class ProcessVisitsCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getGeolocationDataForVisit(Visit $visit): array
|
public function getGeolocationDataForVisit(Visit $visit): Location
|
||||||
{
|
{
|
||||||
if (! $visit->hasRemoteAddr()) {
|
if (! $visit->hasRemoteAddr()) {
|
||||||
$this->output->writeln(
|
$this->output->writeln(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Prophecy\Argument;
|
use Prophecy\Argument;
|
||||||
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\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
@@ -75,16 +76,14 @@ class GetVisitsCommandTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function outputIsProperlyGenerated(): void
|
||||||
*/
|
|
||||||
public function outputIsProperlyGenerated()
|
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
|
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
|
||||||
new Paginator(new ArrayAdapter([
|
new Paginator(new ArrayAdapter([
|
||||||
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
||||||
new VisitLocation(['country_name' => 'Spain'])
|
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, ''))
|
||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
)->shouldBeCalledOnce();
|
)->shouldBeCalledOnce();
|
||||||
|
|||||||
@@ -9,18 +9,17 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||||||
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
|
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
|
||||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
|
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
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;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
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;
|
use Symfony\Component\Lock;
|
||||||
use Throwable;
|
|
||||||
use function array_shift;
|
use function array_shift;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
@@ -60,13 +59,11 @@ class ProcessVisitsCommandTest extends TestCase
|
|||||||
$this->commandTester = new CommandTester($command);
|
$this->commandTester = new CommandTester($command);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function allPendingVisitsAreProcessed(): void
|
||||||
*/
|
|
||||||
public function allPendingVisitsAreProcessed()
|
|
||||||
{
|
{
|
||||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||||
$location = new VisitLocation([]);
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
||||||
function (array $args) use ($visit, $location) {
|
function (array $args) use ($visit, $location) {
|
||||||
@@ -77,7 +74,9 @@ class ProcessVisitsCommandTest extends TestCase
|
|||||||
$secondCallback($location, $visit);
|
$secondCallback($location, $visit);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||||
|
Location::emptyInstance()
|
||||||
|
);
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
'command' => 'visit:process',
|
'command' => 'visit:process',
|
||||||
@@ -93,10 +92,10 @@ class ProcessVisitsCommandTest extends TestCase
|
|||||||
* @test
|
* @test
|
||||||
* @dataProvider provideIgnoredAddresses
|
* @dataProvider provideIgnoredAddresses
|
||||||
*/
|
*/
|
||||||
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message)
|
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
|
||||||
{
|
{
|
||||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
|
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
|
||||||
$location = new VisitLocation([]);
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
||||||
function (array $args) use ($visit, $location) {
|
function (array $args) use ($visit, $location) {
|
||||||
@@ -107,39 +106,32 @@ class ProcessVisitsCommandTest extends TestCase
|
|||||||
$secondCallback($location, $visit);
|
$secondCallback($location, $visit);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||||
|
Location::emptyInstance()
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
$this->commandTester->execute([
|
||||||
$this->commandTester->execute([
|
'command' => 'visit:process',
|
||||||
'command' => 'visit:process',
|
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$output = $this->commandTester->getDisplay();
|
|
||||||
|
|
||||||
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
|
$output = $this->commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString($message, $output);
|
||||||
$this->assertStringContainsString($message, $output);
|
$locateVisits->shouldHaveBeenCalledOnce();
|
||||||
$locateVisits->shouldHaveBeenCalledOnce();
|
$resolveIpLocation->shouldNotHaveBeenCalled();
|
||||||
$resolveIpLocation->shouldNotHaveBeenCalled();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function provideIgnoredAddresses(): array
|
public function provideIgnoredAddresses(): iterable
|
||||||
{
|
{
|
||||||
return [
|
yield 'with empty address' => ['', 'Ignored visit with no IP address'];
|
||||||
['', 'Ignored visit with no IP address'],
|
yield 'with null address' => [null, 'Ignored visit with no IP address'];
|
||||||
[null, 'Ignored visit with no IP address'],
|
yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
|
||||||
[IpAddress::LOCALHOST, 'Ignored localhost address'],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function errorWhileLocatingIpIsDisplayed(): void
|
||||||
*/
|
|
||||||
public function errorWhileLocatingIpIsDisplayed()
|
|
||||||
{
|
{
|
||||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||||
$location = new VisitLocation([]);
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
|
|
||||||
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
|
||||||
function (array $args) use ($visit, $location) {
|
function (array $args) use ($visit, $location) {
|
||||||
@@ -152,19 +144,15 @@ class ProcessVisitsCommandTest extends TestCase
|
|||||||
);
|
);
|
||||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
||||||
|
|
||||||
try {
|
$this->commandTester->execute([
|
||||||
$this->commandTester->execute([
|
'command' => 'visit:process',
|
||||||
'command' => 'visit:process',
|
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
||||||
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$output = $this->commandTester->getDisplay();
|
|
||||||
|
|
||||||
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
|
$this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
|
||||||
$locateVisits->shouldHaveBeenCalledOnce();
|
$locateVisits->shouldHaveBeenCalledOnce();
|
||||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class ChainIpLocationResolver implements IpLocationResolverInterface
|
|||||||
/**
|
/**
|
||||||
* @throws WrongIpException
|
* @throws WrongIpException
|
||||||
*/
|
*/
|
||||||
public function resolveIpLocation(string $ipAddress): array
|
public function resolveIpLocation(string $ipAddress): Model\Location
|
||||||
{
|
{
|
||||||
$error = null;
|
$error = null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,8 @@ class EmptyIpLocationResolver implements IpLocationResolverInterface
|
|||||||
/**
|
/**
|
||||||
* @throws WrongIpException
|
* @throws WrongIpException
|
||||||
*/
|
*/
|
||||||
public function resolveIpLocation(string $ipAddress): array
|
public function resolveIpLocation(string $ipAddress): Model\Location
|
||||||
{
|
{
|
||||||
return [
|
return Model\Location::emptyInstance();
|
||||||
'country_code' => '',
|
|
||||||
'country_name' => '',
|
|
||||||
'region_name' => '',
|
|
||||||
'city' => '',
|
|
||||||
'latitude' => '',
|
|
||||||
'longitude' => '',
|
|
||||||
'time_zone' => '',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class GeoLite2LocationResolver implements IpLocationResolverInterface
|
|||||||
/**
|
/**
|
||||||
* @throws WrongIpException
|
* @throws WrongIpException
|
||||||
*/
|
*/
|
||||||
public function resolveIpLocation(string $ipAddress): array
|
public function resolveIpLocation(string $ipAddress): Model\Location
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$city = $this->geoLiteDbReader->city($ipAddress);
|
$city = $this->geoLiteDbReader->city($ipAddress);
|
||||||
@@ -36,19 +36,19 @@ class GeoLite2LocationResolver implements IpLocationResolverInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mapFields(City $city): array
|
private function mapFields(City $city): Model\Location
|
||||||
{
|
{
|
||||||
/** @var Subdivision $region */
|
/** @var Subdivision $region */
|
||||||
$region = first($city->subdivisions);
|
$region = first($city->subdivisions);
|
||||||
|
|
||||||
return [
|
return new Model\Location(
|
||||||
'country_code' => $city->country->isoCode ?? '',
|
$city->country->isoCode ?? '',
|
||||||
'country_name' => $city->country->name ?? '',
|
$city->country->name ?? '',
|
||||||
'region_name' => $region->name ?? '',
|
$region->name ?? '',
|
||||||
'city' => $city->city->name ?? '',
|
$city->city->name ?? '',
|
||||||
'latitude' => $city->location->latitude ?? '',
|
(float) ($city->location->latitude ?? ''),
|
||||||
'longitude' => $city->location->longitude ?? '',
|
(float) ($city->location->longitude ?? ''),
|
||||||
'time_zone' => $city->location->timeZone ?? '',
|
$city->location->timeZone ?? ''
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class IpApiLocationResolver implements IpLocationResolverInterface
|
|||||||
/**
|
/**
|
||||||
* @throws WrongIpException
|
* @throws WrongIpException
|
||||||
*/
|
*/
|
||||||
public function resolveIpLocation(string $ipAddress): array
|
public function resolveIpLocation(string $ipAddress): Model\Location
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
|
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
|
||||||
@@ -37,16 +37,16 @@ class IpApiLocationResolver implements IpLocationResolverInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mapFields(array $entry): array
|
private function mapFields(array $entry): Model\Location
|
||||||
{
|
{
|
||||||
return [
|
return new Model\Location(
|
||||||
'country_code' => $entry['countryCode'] ?? '',
|
(string) ($entry['countryCode'] ?? ''),
|
||||||
'country_name' => $entry['country'] ?? '',
|
(string) ($entry['country'] ?? ''),
|
||||||
'region_name' => $entry['regionName'] ?? '',
|
(string) ($entry['regionName'] ?? ''),
|
||||||
'city' => $entry['city'] ?? '',
|
(string) ($entry['city'] ?? ''),
|
||||||
'latitude' => $entry['lat'] ?? '',
|
(float) ($entry['lat'] ?? 0.0),
|
||||||
'longitude' => $entry['lon'] ?? '',
|
(float) ($entry['lon'] ?? 0.0),
|
||||||
'time_zone' => $entry['timezone'] ?? '',
|
(string) ($entry['timezone'] ?? '')
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ interface IpLocationResolverInterface
|
|||||||
/**
|
/**
|
||||||
* @throws WrongIpException
|
* @throws WrongIpException
|
||||||
*/
|
*/
|
||||||
public function resolveIpLocation(string $ipAddress): array;
|
public function resolveIpLocation(string $ipAddress): Model\Location;
|
||||||
}
|
}
|
||||||
|
|||||||
80
module/Common/src/IpGeolocation/Model/Location.php
Normal file
80
module/Common/src/IpGeolocation/Model/Location.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Common\IpGeolocation\Model;
|
||||||
|
|
||||||
|
final class Location
|
||||||
|
{
|
||||||
|
/** @var string */
|
||||||
|
private $countryCode;
|
||||||
|
/** @var string */
|
||||||
|
private $countryName;
|
||||||
|
/** @var string */
|
||||||
|
private $regionName;
|
||||||
|
/** @var string */
|
||||||
|
private $city;
|
||||||
|
/** @var float */
|
||||||
|
private $latitude;
|
||||||
|
/** @var float */
|
||||||
|
private $longitude;
|
||||||
|
/** @var string */
|
||||||
|
private $timeZone;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $countryCode,
|
||||||
|
string $countryName,
|
||||||
|
string $regionName,
|
||||||
|
string $city,
|
||||||
|
float $latitude,
|
||||||
|
float $longitude,
|
||||||
|
string $timeZone
|
||||||
|
) {
|
||||||
|
$this->countryCode = $countryCode;
|
||||||
|
$this->countryName = $countryName;
|
||||||
|
$this->regionName = $regionName;
|
||||||
|
$this->city = $city;
|
||||||
|
$this->latitude = $latitude;
|
||||||
|
$this->longitude = $longitude;
|
||||||
|
$this->timeZone = $timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function emptyInstance(): self
|
||||||
|
{
|
||||||
|
return new self('', '', '', '', 0.0, 0.0, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countryCode(): string
|
||||||
|
{
|
||||||
|
return $this->countryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countryName(): string
|
||||||
|
{
|
||||||
|
return $this->countryName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regionName(): string
|
||||||
|
{
|
||||||
|
return $this->regionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function city(): string
|
||||||
|
{
|
||||||
|
return $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latitude(): float
|
||||||
|
{
|
||||||
|
return $this->latitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function longitude(): float
|
||||||
|
{
|
||||||
|
return $this->longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timeZone(): string
|
||||||
|
{
|
||||||
|
return $this->timeZone;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\ChainIpLocationResolver;
|
use Shlinkio\Shlink\Common\IpGeolocation\ChainIpLocationResolver;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
|
|
||||||
class ChainIpLocationResolverTest extends TestCase
|
class ChainIpLocationResolverTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -46,14 +47,12 @@ class ChainIpLocationResolverTest extends TestCase
|
|||||||
$this->resolver->resolveIpLocation($ipAddress);
|
$this->resolver->resolveIpLocation($ipAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function returnsResultOfFirstInnerResolver(): void
|
||||||
*/
|
|
||||||
public function returnsResultOfFirstInnerResolver()
|
|
||||||
{
|
{
|
||||||
$ipAddress = '1.2.3.4';
|
$ipAddress = '1.2.3.4';
|
||||||
|
|
||||||
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
|
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willReturn(Location::emptyInstance());
|
||||||
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
|
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
|
||||||
|
|
||||||
$this->resolver->resolveIpLocation($ipAddress);
|
$this->resolver->resolveIpLocation($ipAddress);
|
||||||
@@ -62,15 +61,15 @@ class ChainIpLocationResolverTest extends TestCase
|
|||||||
$secondResolve->shouldNotHaveBeenCalled();
|
$secondResolve->shouldNotHaveBeenCalled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function returnsResultOfSecondInnerResolver(): void
|
||||||
*/
|
|
||||||
public function returnsResultOfSecondInnerResolver()
|
|
||||||
{
|
{
|
||||||
$ipAddress = '1.2.3.4';
|
$ipAddress = '1.2.3.4';
|
||||||
|
|
||||||
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
|
$firstResolve = $this->firstInnerResolver->resolveIpLocation($ipAddress)->willThrow(WrongIpException::class);
|
||||||
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willReturn([]);
|
$secondResolve = $this->secondInnerResolver->resolveIpLocation($ipAddress)->willReturn(
|
||||||
|
Location::emptyInstance()
|
||||||
|
);
|
||||||
|
|
||||||
$this->resolver->resolveIpLocation($ipAddress);
|
$this->resolver->resolveIpLocation($ipAddress);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace ShlinkioTest\Shlink\Common\IpGeolocation;
|
|||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\EmptyIpLocationResolver;
|
use Shlinkio\Shlink\Common\IpGeolocation\EmptyIpLocationResolver;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||||
use function Functional\map;
|
use function Functional\map;
|
||||||
use function range;
|
use function range;
|
||||||
@@ -13,16 +14,6 @@ class EmptyIpLocationResolverTest extends TestCase
|
|||||||
{
|
{
|
||||||
use StringUtilsTrait;
|
use StringUtilsTrait;
|
||||||
|
|
||||||
private const EMPTY_RESP = [
|
|
||||||
'country_code' => '',
|
|
||||||
'country_name' => '',
|
|
||||||
'region_name' => '',
|
|
||||||
'city' => '',
|
|
||||||
'latitude' => '',
|
|
||||||
'longitude' => '',
|
|
||||||
'time_zone' => '',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var EmptyIpLocationResolver */
|
/** @var EmptyIpLocationResolver */
|
||||||
private $resolver;
|
private $resolver;
|
||||||
|
|
||||||
@@ -35,15 +26,15 @@ class EmptyIpLocationResolverTest extends TestCase
|
|||||||
* @test
|
* @test
|
||||||
* @dataProvider provideEmptyResponses
|
* @dataProvider provideEmptyResponses
|
||||||
*/
|
*/
|
||||||
public function alwaysReturnsAnEmptyResponse(array $expected, string $ipAddress)
|
public function alwaysReturnsAnEmptyResponse(string $ipAddress): void
|
||||||
{
|
{
|
||||||
$this->assertEquals($expected, $this->resolver->resolveIpLocation($ipAddress));
|
$this->assertEquals(Location::emptyInstance(), $this->resolver->resolveIpLocation($ipAddress));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function provideEmptyResponses(): array
|
public function provideEmptyResponses(): array
|
||||||
{
|
{
|
||||||
return map(range(0, 5), function () {
|
return map(range(0, 5), function () {
|
||||||
return [self::EMPTY_RESP, $this->generateRandomString(10)];
|
return [$this->generateRandomString(15)];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2LocationResolver;
|
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2LocationResolver;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
|
|
||||||
class GeoLite2LocationResolverTest extends TestCase
|
class GeoLite2LocationResolverTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -51,10 +52,8 @@ class GeoLite2LocationResolverTest extends TestCase
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function resolvedCityIsProperlyMapped(): void
|
||||||
*/
|
|
||||||
public function resolvedCityIsProperlyMapped()
|
|
||||||
{
|
{
|
||||||
$ipAddress = '1.2.3.4';
|
$ipAddress = '1.2.3.4';
|
||||||
$city = new City([]);
|
$city = new City([]);
|
||||||
@@ -63,15 +62,7 @@ class GeoLite2LocationResolverTest extends TestCase
|
|||||||
|
|
||||||
$result = $this->resolver->resolveIpLocation($ipAddress);
|
$result = $this->resolver->resolveIpLocation($ipAddress);
|
||||||
|
|
||||||
$this->assertEquals([
|
$this->assertEquals(Location::emptyInstance(), $result);
|
||||||
'country_code' => '',
|
|
||||||
'country_name' => '',
|
|
||||||
'region_name' => '',
|
|
||||||
'city' => '',
|
|
||||||
'latitude' => '',
|
|
||||||
'longitude' => '',
|
|
||||||
'time_zone' => '',
|
|
||||||
], $result);
|
|
||||||
$cityMethod->shouldHaveBeenCalledOnce();
|
$cityMethod->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase;
|
|||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
use Shlinkio\Shlink\Common\Exception\WrongIpException;
|
||||||
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
|
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use function json_encode;
|
use function json_encode;
|
||||||
|
|
||||||
class IpApiLocationResolverTest extends TestCase
|
class IpApiLocationResolverTest extends TestCase
|
||||||
@@ -25,25 +26,15 @@ class IpApiLocationResolverTest extends TestCase
|
|||||||
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
|
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function correctIpReturnsDecodedInfo(): void
|
||||||
*/
|
|
||||||
public function correctIpReturnsDecodedInfo()
|
|
||||||
{
|
{
|
||||||
$actual = [
|
$actual = [
|
||||||
'countryCode' => 'bar',
|
'countryCode' => 'bar',
|
||||||
'lat' => 5,
|
'lat' => 5,
|
||||||
'lon' => 10,
|
'lon' => 10,
|
||||||
];
|
];
|
||||||
$expected = [
|
$expected = new Location('bar', '', '', '', 5, 10, '');
|
||||||
'country_code' => 'bar',
|
|
||||||
'country_name' => '',
|
|
||||||
'region_name' => '',
|
|
||||||
'city' => '',
|
|
||||||
'latitude' => 5,
|
|
||||||
'longitude' => 10,
|
|
||||||
'time_zone' => '',
|
|
||||||
];
|
|
||||||
$response = new Response();
|
$response = new Response();
|
||||||
$response->getBody()->write(json_encode($actual));
|
$response->getBody()->write(json_encode($actual));
|
||||||
$response->getBody()->rewind();
|
$response->getBody()->rewind();
|
||||||
@@ -54,7 +45,7 @@ class IpApiLocationResolverTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function guzzleExceptionThrowsShlinkException()
|
public function guzzleExceptionThrowsShlinkException(): void
|
||||||
{
|
{
|
||||||
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
|
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Entity;
|
namespace Shlinkio\Shlink\Core\Entity;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||||
use function array_key_exists;
|
|
||||||
|
|
||||||
class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||||
{
|
{
|
||||||
@@ -24,9 +24,9 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
/** @var string */
|
/** @var string */
|
||||||
private $timezone;
|
private $timezone;
|
||||||
|
|
||||||
public function __construct(array $locationInfo)
|
public function __construct(Location $location)
|
||||||
{
|
{
|
||||||
$this->exchangeArray($locationInfo);
|
$this->exchangeLocationInfo($location);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCountryName(): string
|
public function getCountryName(): string
|
||||||
@@ -49,32 +49,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
|||||||
return $this->cityName ?? '';
|
return $this->cityName ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function exchangeLocationInfo(Location $info): void
|
||||||
* Exchange internal values from provided array
|
|
||||||
*/
|
|
||||||
private function exchangeArray(array $array): void
|
|
||||||
{
|
{
|
||||||
if (array_key_exists('country_code', $array)) {
|
$this->countryCode = $info->countryCode();
|
||||||
$this->countryCode = (string) $array['country_code'];
|
$this->countryName = $info->countryName();
|
||||||
}
|
$this->regionName = $info->regionName();
|
||||||
if (array_key_exists('country_name', $array)) {
|
$this->cityName = $info->city();
|
||||||
$this->countryName = (string) $array['country_name'];
|
$this->latitude = (string) $info->latitude();
|
||||||
}
|
$this->longitude = (string) $info->longitude();
|
||||||
if (array_key_exists('region_name', $array)) {
|
$this->timezone = $info->timeZone();
|
||||||
$this->regionName = (string) $array['region_name'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('city', $array)) {
|
|
||||||
$this->cityName = (string) $array['city'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('latitude', $array)) {
|
|
||||||
$this->latitude = (string) $array['latitude'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('longitude', $array)) {
|
|
||||||
$this->longitude = (string) $array['longitude'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('time_zone', $array)) {
|
|
||||||
$this->timezone = (string) $array['time_zone'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\Core\Service;
|
namespace Shlinkio\Shlink\Core\Service;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
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\Exception\IpCannotBeLocatedException;
|
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||||
@@ -19,7 +20,7 @@ class VisitService implements VisitServiceInterface
|
|||||||
$this->em = $em;
|
$this->em = $em;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function locateVisits(callable $getGeolocationData, ?callable $locatedVisit = null): void
|
public function locateVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void
|
||||||
{
|
{
|
||||||
/** @var VisitRepository $repo */
|
/** @var VisitRepository $repo */
|
||||||
$repo = $this->em->getRepository(Visit::class);
|
$repo = $this->em->getRepository(Visit::class);
|
||||||
@@ -27,26 +28,27 @@ class VisitService implements VisitServiceInterface
|
|||||||
|
|
||||||
foreach ($results as [$visit]) {
|
foreach ($results as [$visit]) {
|
||||||
try {
|
try {
|
||||||
$locationData = $getGeolocationData($visit);
|
/** @var Location $location */
|
||||||
|
$location = $geolocateVisit($visit);
|
||||||
} catch (IpCannotBeLocatedException $e) {
|
} catch (IpCannotBeLocatedException $e) {
|
||||||
// Skip if the visit's IP could not be located
|
// Skip if the visit's IP could not be located
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$location = new VisitLocation($locationData);
|
$location = new VisitLocation($location);
|
||||||
$this->locateVisit($visit, $location, $locatedVisit);
|
$this->locateVisit($visit, $location, $notifyVisitWithLocation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function locateVisit(Visit $visit, VisitLocation $location, ?callable $locatedVisit): void
|
private function locateVisit(Visit $visit, VisitLocation $location, ?callable $notifyVisitWithLocation): void
|
||||||
{
|
{
|
||||||
$visit->locate($location);
|
$visit->locate($location);
|
||||||
|
|
||||||
$this->em->persist($visit);
|
$this->em->persist($visit);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
if ($locatedVisit !== null) {
|
if ($notifyVisitWithLocation !== null) {
|
||||||
$locatedVisit($location, $visit);
|
$notifyVisitWithLocation($location, $visit);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->em->clear();
|
$this->em->clear();
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ namespace Shlinkio\Shlink\Core\Service;
|
|||||||
|
|
||||||
interface VisitServiceInterface
|
interface VisitServiceInterface
|
||||||
{
|
{
|
||||||
public function locateVisits(callable $getGeolocationData, ?callable $locatedVisit = null): void;
|
public function locateVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
@@ -29,10 +30,8 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||||||
$this->repo = $this->getEntityManager()->getRepository(Visit::class);
|
$this->repo = $this->getEntityManager()->getRepository(Visit::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function findUnlocatedVisitsReturnsProperVisits(): void
|
||||||
*/
|
|
||||||
public function findUnlocatedVisitsReturnsProperVisits()
|
|
||||||
{
|
{
|
||||||
$shortUrl = new ShortUrl('');
|
$shortUrl = new ShortUrl('');
|
||||||
$this->getEntityManager()->persist($shortUrl);
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
@@ -41,7 +40,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||||
|
|
||||||
if ($i % 2 === 0) {
|
if ($i % 2 === 0) {
|
||||||
$location = new VisitLocation([]);
|
$location = new VisitLocation(Location::emptyInstance());
|
||||||
$this->getEntityManager()->persist($location);
|
$this->getEntityManager()->persist($location);
|
||||||
$visit->locate($location);
|
$visit->locate($location);
|
||||||
}
|
}
|
||||||
@@ -59,10 +58,8 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||||||
$this->assertEquals(3, $resultsCount);
|
$this->assertEquals(3, $resultsCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function findVisitsByShortCodeReturnsProperData(): void
|
||||||
*/
|
|
||||||
public function findVisitsByShortCodeReturnsProperData()
|
|
||||||
{
|
{
|
||||||
$shortUrl = new ShortUrl('');
|
$shortUrl = new ShortUrl('');
|
||||||
$this->getEntityManager()->persist($shortUrl);
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
@@ -86,10 +83,8 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 5, 4));
|
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 5, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function countVisitsByShortCodeReturnsProperData(): void
|
||||||
*/
|
|
||||||
public function countVisitsByShortCodeReturnsProperData()
|
|
||||||
{
|
{
|
||||||
$shortUrl = new ShortUrl('');
|
$shortUrl = new ShortUrl('');
|
||||||
$this->getEntityManager()->persist($shortUrl);
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
|
|||||||
@@ -4,20 +4,15 @@ declare(strict_types=1);
|
|||||||
namespace ShlinkioTest\Shlink\Core\Entity;
|
namespace ShlinkioTest\Shlink\Core\Entity;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
|
|
||||||
class VisitLocationTest extends TestCase
|
class VisitLocationTest extends TestCase
|
||||||
{
|
{
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function valuesFoundWhenExchangingArrayAreCastToString(): void
|
||||||
*/
|
|
||||||
public function valuesFoundWhenExchangingArrayAreCastToString()
|
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = new Location('', '', '', '', 1000.7, -2000.4, '');
|
||||||
'latitude' => 1000.7,
|
|
||||||
'longitude' => -2000.4,
|
|
||||||
];
|
|
||||||
|
|
||||||
$location = new VisitLocation($payload);
|
$location = new VisitLocation($payload);
|
||||||
|
|
||||||
$this->assertSame('1000.7', $location->getLatitude());
|
$this->assertSame('1000.7', $location->getLatitude());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Doctrine\ORM\EntityManager;
|
|||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\Argument;
|
use Prophecy\Argument;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
|
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
@@ -31,10 +32,8 @@ class VisitServiceTest extends TestCase
|
|||||||
$this->visitService = new VisitService($this->em->reveal());
|
$this->visitService = new VisitService($this->em->reveal());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @test */
|
||||||
* @test
|
public function locateVisitsIteratesAndLocatesUnlocatedVisits(): void
|
||||||
*/
|
|
||||||
public function locateVisitsIteratesAndLocatesUnlocatedVisits()
|
|
||||||
{
|
{
|
||||||
$unlocatedVisits = [
|
$unlocatedVisits = [
|
||||||
[new Visit(new ShortUrl('foo'), Visitor::emptyInstance())],
|
[new Visit(new ShortUrl('foo'), Visitor::emptyInstance())],
|
||||||
@@ -53,7 +52,7 @@ class VisitServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->visitService->locateVisits(function () {
|
$this->visitService->locateVisits(function () {
|
||||||
return [];
|
return Location::emptyInstance();
|
||||||
}, function () {
|
}, function () {
|
||||||
$args = func_get_args();
|
$args = func_get_args();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user