Extend and normalize output from visits console commands

This commit is contained in:
Alejandro Celaya
2026-01-03 11:45:29 +01:00
parent 0d964f0fde
commit 900de9e800
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. 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. * [#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. * [#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\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::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\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
@@ -119,11 +119,11 @@ return [
Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::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\ListDomainsCommand::class => [DomainService::class],
Command\Domain\DomainRedirectsCommand::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 => [ Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::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\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput; 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\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\Argument;
@@ -22,10 +20,8 @@ class GetDomainVisitsCommand extends Command
{ {
public const string NAME = 'domain:visits'; public const string NAME = 'domain:visits';
public function __construct( public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
private readonly VisitsStatsHelperInterface $visitsHelper, {
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct(); parent::__construct();
} }
@@ -36,17 +32,8 @@ class GetDomainVisitsCommand extends Command
#[MapInput] VisitsListInput $input, #[MapInput] VisitsListInput $input,
): int { ): int {
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($input->dateRange())); $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 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\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsListInput; use Shlinkio\Shlink\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; 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\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\Argument;
@@ -24,10 +22,8 @@ class GetTagVisitsCommand extends Command
{ {
public const string NAME = 'tag:visits'; public const string NAME = 'tag:visits';
public function __construct( public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
private readonly VisitsStatsHelperInterface $visitsHelper, {
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct(); parent::__construct();
} }
@@ -47,17 +43,8 @@ class GetTagVisitsCommand extends Command
domain: $domain, domain: $domain,
)); ));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS; 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\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; 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\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -21,10 +19,8 @@ class GetNonOrphanVisitsCommand extends Command
{ {
public const string NAME = 'visit:non-orphan'; public const string NAME = 'visit:non-orphan';
public function __construct( public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
private readonly VisitsStatsHelperInterface $visitsHelper, {
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct(); parent::__construct();
} }
@@ -42,17 +38,8 @@ class GetNonOrphanVisitsCommand extends Command
dateRange: $input->dateRange(), dateRange: $input->dateRange(),
domain: $domain, domain: $domain,
)); ));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS; 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\CLI\Input\VisitsListInput;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; 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\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
@@ -42,16 +41,8 @@ class GetOrphanVisitsCommand extends Command
domain: $domain, domain: $domain,
type: $type, type: $type,
)); ));
VisitsCommandUtils::renderOutput($io, $input, $paginator, $this->mapExtraFields(...)); VisitsCommandUtils::renderOutput($io, $input, $paginator);
return self::SUCCESS; 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 Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function array_map; use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
class VisitsCommandUtils class VisitsCommandUtils
{ {
/** /**
* @param Paginator<Visit> $paginator * @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/ */
public static function renderOutput( public static function renderOutput(
OutputInterface $output, OutputInterface $output,
@@ -36,25 +32,21 @@ class VisitsCommandUtils
} }
match ($inputData->format) { match ($inputData->format) {
VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator, $mapExtraFields), VisitsListFormat::CSV => self::renderCSVOutput($output, $paginator),
default => self::renderHumanFriendlyOutput($output, $paginator, $mapExtraFields), default => self::renderHumanFriendlyOutput($output, $paginator),
}; };
} }
/** /**
* @param Paginator<Visit> $paginator * @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/ */
private static function renderCSVOutput( private static function renderCSVOutput(OutputInterface $output, Paginator $paginator): void
OutputInterface $output, {
Paginator $paginator,
callable|null $mapExtraFields,
): void {
$page = 1; $page = 1;
do { do {
$paginator->setCurrentPage($page); $paginator->setCurrentPage($page);
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); [$rows, $headers] = self::resolveRowsAndHeaders($paginator);
$csv = Writer::fromString(); $csv = Writer::fromString();
if ($page === 1) { if ($page === 1) {
$csv->insertOne($headers); $csv->insertOne($headers);
@@ -69,19 +61,15 @@ class VisitsCommandUtils
/** /**
* @param Paginator<Visit> $paginator * @param Paginator<Visit> $paginator
* @param null|callable(Visit $visits): array<string, string> $mapExtraFields
*/ */
private static function renderHumanFriendlyOutput( private static function renderHumanFriendlyOutput(OutputInterface $output, Paginator $paginator): void
OutputInterface $output, {
Paginator $paginator,
callable|null $mapExtraFields,
): void {
$page = 1; $page = 1;
do { do {
$paginator->setCurrentPage($page); $paginator->setCurrentPage($page);
$page++; $page++;
[$rows, $headers] = self::resolveRowsAndHeaders($paginator, $mapExtraFields); [$rows, $headers] = self::resolveRowsAndHeaders($paginator);
ShlinkTable::default($output)->render( ShlinkTable::default($output)->render(
$headers, $headers,
$rows, $rows,
@@ -92,35 +80,38 @@ class VisitsCommandUtils
/** /**
* @param Paginator<Visit> $paginator * @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; $headers = [
$mapExtraFields ??= static fn (Visit $_) => []; 'Date',
'Potential bot',
$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) { 'User agent',
$extraFields = $mapExtraFields($visit); 'Referer',
$extraKeys ??= array_keys($extraFields); 'Country',
'Region',
$rowData = [ 'City',
'referer' => $visit->referer, 'Visited URL',
'date' => $visit->date->toAtomString(), 'Redirect URL',
'userAgent' => $visit->userAgent, 'Type',
'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],
]; ];
$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\CLI\Command\Domain\GetDomainVisitsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; 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\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
@@ -24,16 +24,11 @@ class GetDomainVisitsCommandTest extends TestCase
{ {
private CommandTester $commandTester; private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper; private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
protected function setUp(): void protected function setUp(): void
{ {
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); $this->commandTester = CliTestUtils::testerForCommand(new GetDomainVisitsCommand($this->visitsHelper));
$this->commandTester = CliTestUtils::testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
);
} }
#[Test] #[Test]
@@ -48,22 +43,22 @@ class GetDomainVisitsCommandTest extends TestCase
$domain, $domain,
$this->anything(), $this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit]))); )->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'the_short_url',
);
$this->commandTester->execute(['domain' => $domain]); $this->commandTester->execute(['domain' => $domain]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$type = VisitType::VALID_SHORT_URL->value;
self::assertEquals( self::assertEquals(
// phpcs:disable Generic.Files.LineLength
<<<OUTPUT <<<OUTPUT
+---------+---------------------------+------------+---------+--------+---------------+ +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| Referer | Date | User agent | Country | City | Short Url | | Date | Potential bot | User agent | Referer | Country | Region | City | Visited URL | Redirect URL | Type |
+---------+---------------------------+------------+---------+--------+---------------+ +---------------------------+---------------+------------+---------+---------+--------+--------+-------------+--------------+-----------------+
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url | | {$visit->date->toAtomString()} | | bar | foo | Spain | | Madrid | | Unknown | {$type} |
+---------+-------------------------- Page 1 of 1 -+---------+--------+---------------+ +---------------------------+---------------+------------+------- Page 1 of 1 --------+--------+-------------+--------------+-----------------+
OUTPUT, OUTPUT,
// phpcs:enable
$output, $output,
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ArrayUtils; namespace Shlinkio\Shlink\Core\ArrayUtils;
use function array_filter;
use function array_reduce;
use function in_array; use function in_array;
use const ARRAY_FILTER_USE_KEY;
/** /**
* @template T * @template T
* @param T $value * @param T $value
@@ -20,18 +16,6 @@ function contains(mixed $value, array $array): bool
return in_array($value, $array, strict: true); 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. * Checks if a callback returns true for at least one item in a collection.
* @param callable(mixed $value, mixed $key): bool $callback * @param callable(mixed $value, mixed $key): bool $callback
@@ -62,21 +46,6 @@ function every(iterable $collection, callable $callback): bool
return true; 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 T
* @template R * @template R

View File

@@ -58,7 +58,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface
->setUrlReferrer($visit->referer) ->setUrlReferrer($visit->referer)
->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString()); ->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString());
$location = $visit->getVisitLocation(); $location = $visit->visitLocation;
if ($location !== null) { if ($location !== null) {
$tracker $tracker
->setCity($location->cityName) ->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 $remoteAddr = null,
public readonly string|null $visitedUrl = null, public readonly string|null $visitedUrl = null,
public readonly string|null $redirectUrl = null, public readonly string|null $redirectUrl = null,
private VisitLocation|null $visitLocation = null, private(set) VisitLocation|null $visitLocation = null,
public readonly Chronos $date = new Chronos(), public readonly Chronos $date = new Chronos(),
) { ) {
} }
@@ -124,11 +124,6 @@ class Visit extends AbstractEntity implements JsonSerializable
return ! empty($this->remoteAddr); return ! empty($this->remoteAddr);
} }
public function getVisitLocation(): VisitLocation|null
{
return $this->visitLocation;
}
public function locate(VisitLocation $visitLocation): self public function locate(VisitLocation $visitLocation): self
{ {
$this->visitLocation = $visitLocation; $this->visitLocation = $visitLocation;

View File

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