Merge pull request #2563 from acelaya-forks/extended-visits-cli-output

Extend and normalize output from visits console commands
This commit is contained in:
Alejandro Celaya
2026-01-03 11:59:50 +01:00
committed by GitHub
16 changed files with 105 additions and 200 deletions

View File

@@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
* [#2311](https://github.com/shlinkio/shlink/issues/2311) All visits-related commands now return more information, and columns are arranged slightly differently.
Among other things, they now always return the type of the visit, region, visited URL, redirected URL and whether the visit comes from a potential bot or not.
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.

View File

@@ -107,7 +107,7 @@ return [
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -119,11 +119,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,

View File

@@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
@@ -22,10 +20,8 @@ class GetDomainVisitsCommand extends Command
{
public const string NAME = 'domain:visits';
public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
@@ -36,17 +32,8 @@ class GetDomainVisitsCommand extends Command
#[MapInput] VisitsListInput $input,
): int {
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange()));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
@@ -24,10 +22,8 @@ class GetTagVisitsCommand extends Command
{
public const string NAME = 'tag:visits';
public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
@@ -47,17 +43,8 @@ class GetTagVisitsCommand extends Command
domain: $domain,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -6,8 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -21,10 +19,8 @@ class GetNonOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:non-orphan';
public function __construct(
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
}
@@ -42,17 +38,8 @@ class GetNonOrphanVisitsCommand extends Command
dateRange: $input->dateRange(),
domain: $domain,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];
}
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
@@ -42,16 +41,8 @@ class GetOrphanVisitsCommand extends Command
domain: $domain,
type: $type,
));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...));
VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
private function mapExtraFields(Visit $visit): array
{
return ['type' => $visit->type->value];
}
}

View File

@@ -13,16 +13,12 @@ use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
class VisitsCommandUtils
{
/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
public static function renderOutput(
OutputInterface $output,
@@ -36,25 +32,21 @@ class VisitsCommandUtils
}
match ($inputData->format) {
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields),
default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields),
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
default => self::renderHumanFriendlyOutput($output, $paginator),
};
}
/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function renderCSVOutput(
OutputInterface $output,
Paginator $paginator,
callable|null $mapExtraFields,
): void {
private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
$csv = Writer::fromString();
if ($page === 1) {
$csv->insertOne($headers);
@@ -69,19 +61,15 @@ class VisitsCommandUtils
/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function renderHumanFriendlyOutput(
OutputInterface $output,
Paginator $paginator,
callable|null $mapExtraFields,
): void {
private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
{
$page = 1;
do {
$paginator->setCurrentPage($page);
$page++;
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render(
$headers,
$rows,
@@ -92,35 +80,38 @@ class VisitsCommandUtils
/**
* @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/
private static function resolveRowsAndHeaders(Paginator $paginator, callable|null $mapExtraFields): array
private static function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = null;
$mapExtraFields ??= static fn (Visit $_) => [];
$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) {
$extraFields = $mapExtraFields($visit);
$extraKeys ??= array_keys($extraFields);
$rowData = [
'referer' => $visit->referer,
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];
// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}, [...$paginator->getCurrentPageResults()]);
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys ?? []);
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
$headers = [
'Date',
'Potential bot',
'User agent',
'Referer',
'Country',
'Region',
'City',
'Visited URL',
'Redirect URL',
'Type',
];
$rows = array_map(function (Visit $visit) {
$visitLocation = $visit->visitLocation;
return [
'date' => $visit->date->toAtomString(),
'potentialBot' => $visit->potentialBot ? 'Potential bot' : '',
'userAgent' => $visit->userAgent,
'referer' => $visit->referer,
'country' => $visitLocation->countryName ?? 'Unknown',
'region' => $visitLocation->regionName ?? 'Unknown',
'city' => $visitLocation->cityName ?? 'Unknown',
'visitedUrl' => $visit->visitedUrl ?? 'Unknown',
'redirectUrl' => $visit->redirectUrl ?? 'Unknown',
'type' => $visit->type->value,
];
}, [...$paginator->getCurrentPageResults()]);
return [$rows, $headers];
}
}

View File

@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = CliTestUtils::testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -48,22 +43,22 @@ class GetDomainVisitsCommandTest extends TestCase
$domain,
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'the_short_url',
);
$this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -107,20 +107,22 @@ class GetShortUrlVisitsCommandTest extends TestCase
{
yield 'regular' => [
VisitsListFormat::FULL,
// phpcs:disable Generic.Files.LineLength
static fn (Chronos $date) => <<<OUTPUT
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$date->toAtomString()} | bar | Spain | Madrid |
+---------+------------------ Page 1 of 1 ---------+---------+--------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | valid_short_url |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
];
yield 'CSV' => [
VisitsListFormat::CSV,
static fn (Chronos $date) => <<<OUTPUT
Referer,Date,"User agent",Country,City
foo,{$date->toAtomString()},bar,Spain,Madrid
Date,"Potential bot","User agent",Referer,Country,Region,City,"Visited URL","Redirect URL",Type
{$date->toAtomString()},,bar,foo,Spain,,Madrid,,Unknown,valid_short_url
OUTPUT,
];

View File

@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetTagVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = CliTestUtils::testerForCommand(
new GetTagVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetTagVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -47,20 +42,22 @@ class GetTagVisitsCommandTest extends TestCase
$this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute(['tag' => $tag]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -11,10 +11,10 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetNonOrphanVisitsCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = CliTestUtils::testerForCommand(
new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier),
);
$this->commandTester = CliTestUtils::testerForCommand(new GetNonOrphanVisitsCommand($this->visitsHelper));
}
#[Test]
@@ -46,20 +41,22 @@ class GetNonOrphanVisitsCommandTest extends TestCase
$this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn(
new Paginator(new ArrayAdapter([$visit])),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url');
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -48,16 +48,19 @@ class GetOrphanVisitsCommandTest extends TestCase
$this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
$type = OrphanVisitType::BASE_URL->value;
self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+----------------------- Page 1 of 1 ----+---------+--------+----------+
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+
| Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+----------+
| {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------------------------+---------------+------------+--- Page 1 of 1 ---+--------+--------+-------------+--------------+----------+
OUTPUT,
// phpcs:enable
$output,
);
}

View File

@@ -4,12 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ArrayUtils;
use function array_filter;
use function array_reduce;
use function in_array;
use const ARRAY_FILTER_USE_KEY;
/**
* @template T
* @param T $value
@@ -20,18 +16,6 @@ function contains(mixed $value, array $array): bool
return in_array($value, $array, strict: true);
}
/**
* @param array[] $multiArray
*/
function flatten(array $multiArray): array
{
return array_reduce(
$multiArray,
static fn (array $carry, array $value) => [...$carry, ...$value],
initial: [],
);
}
/**
* Checks if a callback returns true for at least one item in a collection.
* @param callable(mixed $value, mixed $key): bool $callback
@@ -62,21 +46,6 @@ function every(iterable $collection, callable $callback): bool
return true;
}
/**
* Returns an array containing only those entries in the array whose key is in the supplied keys.
*/
function select_keys(array $array, array $keys): array
{
return array_filter(
$array,
static fn (string $key) => contains(
$key,
$keys,
),
ARRAY_FILTER_USE_KEY,
);
}
/**
* @template T
* @template R

View File

@@ -58,7 +58,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface
->setUrlReferrer($visit->referer)
->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString());
$location = $visit->getVisitLocation();
$location = $visit->visitLocation;
if ($location !== null) {
$tracker
->setCity($location->cityName)

View File

@@ -29,7 +29,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public readonly string|null $remoteAddr = null,
public readonly string|null $visitedUrl = null,
public readonly string|null $redirectUrl = null,
private VisitLocation|null $visitLocation = null,
private(set) VisitLocation|null $visitLocation = null,
public readonly Chronos $date = new Chronos(),
) {
}
@@ -124,11 +124,6 @@ class Visit extends AbstractEntity implements JsonSerializable
return ! empty($this->remoteAddr);
}
public function getVisitLocation(): VisitLocation|null
{
return $this->visitLocation;
}
public function locate(VisitLocation $visitLocation): self
{
$this->visitLocation = $visitLocation;

View File

@@ -72,7 +72,7 @@ readonly class VisitLocator implements VisitLocatorInterface
private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void
{
$prevLocation = $visit->getVisitLocation();
$prevLocation = $visit->visitLocation;
$visit->locate($location);
$this->em->persist($visit);