Merge pull request #2098 from acelaya-forks/feature/matomo-command

Create console command to send visits to matomo
This commit is contained in:
Alejandro Celaya 2024-04-13 20:59:57 +02:00 committed by GitHub
commit 048856c333
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 771 additions and 213 deletions

View file

@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space.
* [#1925](https://github.com/shlinkio/shlink/issues/1925) Add new `integration:matomo:send-visits` console command that can be used to send existing visits to integrated Matomo instance.
### Changed
* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible.
* [#2036](https://github.com/shlinkio/shlink/issues/2036) Deep performance improvement in some endpoints which involve counting visits:

View file

@ -118,7 +118,7 @@
"@parallel test:unit test:db",
"@parallel test:api test:cli"
],
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit": "COLUMNS=120 vendor/bin/phpunit --order-by=random --colors=always --testdox",
"test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov",
"test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html",
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",

View file

@ -42,6 +42,8 @@ return [
Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
Command\RedirectRule\ManageRedirectRulesCommand::class,
Command\Integration\MatomoSendVisitsCommand::NAME => Command\Integration\MatomoSendVisitsCommand::class,
],
],

View file

@ -8,6 +8,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Matomo;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
@ -71,6 +72,8 @@ return [
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
Command\Integration\MatomoSendVisitsCommand::class => ConfigAbstractFactory::class,
],
],
@ -129,6 +132,11 @@ return [
RedirectRule\RedirectRuleHandler::class,
],
Command\Integration\MatomoSendVisitsCommand::class => [
Matomo\MatomoOptions::class,
Matomo\MatomoVisitSender::class,
],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
Util\ProcessRunner::class,

View file

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function sprintf;
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
{
public const NAME = 'integration:matomo:send-visits';
private readonly bool $matomoEnabled;
private SymfonyStyle $io;
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
{
$this->matomoEnabled = $matomoOptions->enabled;
parent::__construct();
}
protected function configure(): void
{
$help = <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
history tracked there.
This command will unconditionally send to Matomo all visits for a specific date range, so make sure you
provide the proper limits to avoid duplicated visits.
Send all visits created so far:
<info>%command.name%</info>
Send all visits created before 2024:
<info>%command.name% --until 2023-12-31</info>
Send all visits created after a specific day:
<info>%command.name% --since 2022-03-27</info>
Send all visits created during 2022:
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
HELP;
$this
->setName(self::NAME)
->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
))
->setHelp($help)
->addOption(
'since',
's',
InputOption::VALUE_REQUIRED,
'Only visits created since this date, inclusively, will be sent to Matomo',
)
->addOption(
'until',
'u',
InputOption::VALUE_REQUIRED,
'Only visits created until this date, inclusively, will be sent to Matomo',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->io = new SymfonyStyle($input, $output);
if (! $this->matomoEnabled) {
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
return ExitCode::EXIT_WARNING;
}
// TODO Validate provided date formats
$since = $input->getOption('since');
$until = $input->getOption('until');
$dateRange = buildDateRange(
startDate: $since !== null ? Chronos::parse($since) : null,
endDate: $until !== null ? Chronos::parse($until) : null,
);
if ($input->isInteractive()) {
$this->io->warning([
'You are about to send visits from this Shlink instance to Matomo',
'Resolved date range -> ' . dateRangeToHumanFriendly($dateRange),
'Shlink will not check for already sent visits, which could result in some duplications. Make sure '
. 'you have verified only visits in the right date range are going to be sent.',
]);
if (! $this->io->confirm('Continue?', default: false)) {
return ExitCode::EXIT_WARNING;
}
}
$result = $this->visitSender->sendVisitsInDateRange($dateRange, $this);
match (true) {
$result->hasFailures() && $result->hasSuccesses() => $this->io->warning(
sprintf('%s visits sent to Matomo. %s failed.', $result->successfulVisits, $result->failedVisits),
),
$result->hasFailures() => $this->io->error(
sprintf('Failed to send %s visits to Matomo.', $result->failedVisits),
),
$result->hasSuccesses() => $this->io->success(
sprintf('%s visits sent to Matomo.', $result->successfulVisits),
),
default => $this->io->info('There was no visits matching provided date range.'),
};
return ExitCode::EXIT_SUCCESS;
}
public function success(int $index): void
{
$this->io->write('.');
}
public function error(int $index, Throwable $e): void
{
$this->io->write('<error>E</error>');
if ($this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
}
}

View file

@ -55,7 +55,7 @@ abstract class AbstractVisitsListCommand extends Command
$rowData = [
'referer' => $visit->referer,
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown',

View file

@ -60,7 +60,7 @@ class GetDomainVisitsCommandTest extends TestCase
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,

View file

@ -0,0 +1,135 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Integration;
use Exception;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Integration\MatomoSendVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
class MatomoSendVisitsCommandTest extends TestCase
{
private MockObject & MatomoVisitSenderInterface $visitSender;
protected function setUp(): void
{
$this->visitSender = $this->createMock(MatomoVisitSenderInterface::class);
}
#[Test]
public function warningDisplayedIfIntegrationIsNotEnabled(): void
{
[$output, $exitCode] = $this->executeCommand(matomoEnabled: false);
self::assertStringContainsString('Matomo integration is not enabled in this Shlink instance', $output);
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
}
#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function warningIsOnlyDisplayedInInteractiveMode(bool $interactive): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturn(new SendVisitsResult());
[$output] = $this->executeCommand(['y'], ['interactive' => $interactive]);
if ($interactive) {
self::assertStringContainsString('You are about to send visits', $output);
} else {
self::assertStringNotContainsString('You are about to send visits', $output);
}
}
#[Test]
#[TestWith([true])]
#[TestWith([false])]
public function canCancelExecutionInInteractiveMode(bool $interactive): void
{
$this->visitSender->expects($this->exactly($interactive ? 0 : 1))->method('sendVisitsInDateRange')->willReturn(
new SendVisitsResult(),
);
$this->executeCommand(['n'], ['interactive' => $interactive]);
}
#[Test]
#[TestWith([new SendVisitsResult(), 'There was no visits matching provided date range'])]
#[TestWith([new SendVisitsResult(successfulVisits: 10), '10 visits sent to Matomo.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 2), '2 visits sent to Matomo.'])]
#[TestWith([new SendVisitsResult(failedVisits: 238), 'Failed to send 238 visits to Matomo.'])]
#[TestWith([new SendVisitsResult(failedVisits: 18), 'Failed to send 18 visits to Matomo.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 2, failedVisits: 35), '2 visits sent to Matomo. 35 failed.'])]
#[TestWith([new SendVisitsResult(successfulVisits: 81, failedVisits: 6), '81 visits sent to Matomo. 6 failed.'])]
public function expectedResultIsDisplayed(SendVisitsResult $result, string $expectedResultMessage): void
{
$this->visitSender->expects($this->once())->method('sendVisitsInDateRange')->willReturn($result);
[$output, $exitCode] = $this->executeCommand(['y']);
self::assertStringContainsString($expectedResultMessage, $output);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
#[Test]
public function printsResultOfSendingVisits(): void
{
$this->visitSender->method('sendVisitsInDateRange')->willReturnCallback(
function (DateRange $_, MatomoSendVisitsCommand $command): SendVisitsResult {
// Call it a few times for an easier match of its result in the command putput
$command->success(0);
$command->success(1);
$command->success(2);
$command->error(3, new Exception('Error'));
$command->success(4);
$command->error(5, new Exception('Error'));
return new SendVisitsResult();
},
);
[$output] = $this->executeCommand(['y']);
self::assertStringContainsString('...E.E', $output);
}
#[Test]
#[TestWith([[], 'All time'])]
#[TestWith([['--since' => '2023-05-01'], 'Since 2023-05-01 00:00:00'])]
#[TestWith([['--until' => '2023-05-01'], 'Until 2023-05-01 00:00:00'])]
#[TestWith([
['--since' => '2023-05-01', '--until' => '2024-02-02 23:59:59'],
'Between 2023-05-01 00:00:00 and 2024-02-02 23:59:59',
])]
public function providedDateAreParsed(array $args, string $expectedMessage): void
{
[$output] = $this->executeCommand(['n'], args: $args);
self::assertStringContainsString('Resolved date range -> ' . $expectedMessage, $output);
}
/**
* @return array{string, int, MatomoSendVisitsCommand}
*/
private function executeCommand(
array $input = [],
array $options = [],
array $args = [],
bool $matomoEnabled = true,
): array {
$command = new MatomoSendVisitsCommand(new MatomoOptions(enabled: $matomoEnabled), $this->visitSender);
$commandTester = CliTestUtils::testerForCommand($command);
$commandTester->setInputs($input);
$commandTester->execute($args, $options);
$output = $commandTester->getDisplay();
$exitCode = $commandTester->getStatusCode();
return [$output, $exitCode, $command];
}
}

View file

@ -110,7 +110,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
+---------+---------------------------+------------+---------+--------+
| Referer | Date | User agent | Country | City |
+---------+---------------------------+------------+---------+--------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid |
+---------+---------------------------+------------+---------+--------+
OUTPUT,

View file

@ -57,7 +57,7 @@ class GetTagVisitsCommandTest extends TestCase
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,

View file

@ -56,7 +56,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
+---------+---------------------------+------------+---------+--------+---------------+
| Referer | Date | User agent | Country | City | Short Url |
+---------+---------------------------+------------+---------+--------+---------------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | the_short_url |
+---------+---------------------------+------------+---------+--------+---------------+
OUTPUT,

View file

@ -54,7 +54,7 @@ class GetOrphanVisitsCommandTest extends TestCase
+---------+---------------------------+------------+---------+--------+----------+
| Referer | Date | User agent | Country | City | Type |
+---------+---------------------------+------------+---------+--------+----------+
| foo | {$visit->getDate()->toAtomString()} | bar | Spain | Madrid | base_url |
| foo | {$visit->date->toAtomString()} | bar | Spain | Madrid | base_url |
+---------+---------------------------+------------+---------+--------+----------+
OUTPUT,

View file

@ -10,6 +10,7 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Lock;
@ -72,7 +73,7 @@ return [
Visit\Geolocation\VisitLocator::class => ConfigAbstractFactory::class,
Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Visit\Repository\VisitLocationRepository::class => [
Visit\Repository\VisitIterationRepository::class => [
EntityRepositoryFactory::class,
Visit\Entity\Visit::class,
],
@ -101,6 +102,7 @@ return [
Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'],
Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class,
Matomo\MatomoVisitSender::class => ConfigAbstractFactory::class,
],
'aliases' => [
@ -110,6 +112,11 @@ return [
ConfigAbstractFactory::class => [
Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class],
Matomo\MatomoVisitSender::class => [
Matomo\MatomoTrackerBuilder::class,
ShortUrlStringifier::class,
Visit\Repository\VisitIterationRepository::class,
],
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
@ -143,7 +150,7 @@ return [
ShortUrl\Repository\ShortUrlListRepository::class,
Options\UrlShortenerOptions::class,
],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitLocationRepository::class],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],

View file

@ -12,7 +12,6 @@ use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
@ -157,9 +156,8 @@ return (static function (): array {
EventDispatcher\Matomo\SendVisitToMatomo::class => [
'em',
'Logger_Shlink',
ShortUrlStringifier::class,
Matomo\MatomoOptions::class,
Matomo\MatomoTrackerBuilder::class,
Matomo\MatomoVisitSender::class,
],
EventDispatcher\UpdateGeoLiteDb::class => [

View file

@ -61,6 +61,23 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function dateRangeToHumanFriendly(?DateRange $dateRange): string
{
$startDate = $dateRange?->startDate;
$endDate = $dateRange?->endDate;
return match (true) {
$startDate !== null && $endDate !== null => sprintf(
'Between %s and %s',
$startDate->toDateTimeString(),
$endDate->toDateTimeString(),
),
$startDate !== null => sprintf('Since %s', $startDate->toDateTimeString()),
$endDate !== null => sprintf('Until %s', $endDate->toDateTimeString()),
default => 'All time',
};
}
/**
* @return ($date is null ? null : Chronos)
*/

View file

@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Config\PostProcessor;
use Fig\Http\Message\RequestMethodInterface;
use Mezzio\Router\Route;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Util\RedirectStatus;
@ -40,9 +38,7 @@ class ShortUrlMethodsProcessor
$redirectStatus = RedirectStatus::tryFrom(
$config['redirects']['redirect_status_code'] ?? 0,
) ?? DEFAULT_REDIRECT_STATUS_CODE;
$redirectRoute['allowed_methods'] = $redirectStatus->isLegacyStatus()
? [RequestMethodInterface::METHOD_GET]
: Route::HTTP_METHOD_ANY;
$redirectRoute['allowed_methods'] = $redirectStatus->allowedHttpMethods();
$config['routes'] = [...$rest, $redirectRoute];
return $config;

View file

@ -8,8 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
@ -18,9 +17,8 @@ readonly class SendVisitToMatomo
public function __construct(
private EntityManagerInterface $em,
private LoggerInterface $logger,
private ShortUrlStringifier $shortUrlStringifier,
private MatomoOptions $matomoOptions,
private MatomoTrackerBuilderInterface $trackerBuilder,
private MatomoVisitSenderInterface $visitSender,
) {
}
@ -42,48 +40,10 @@ readonly class SendVisitToMatomo
}
try {
$tracker = $this->trackerBuilder->buildMatomoTracker();
$tracker
->setUrl($this->resolveUrlToTrack($visit))
->setCustomTrackingParameter('type', $visit->type->value)
->setUserAgent($visit->userAgent)
->setUrlReferrer($visit->referer);
$location = $visit->getVisitLocation();
if ($location !== null) {
$tracker
->setCity($location->cityName)
->setCountry($location->countryName)
->setLatitude($location->latitude)
->setLongitude($location->longitude);
}
// Set not obfuscated IP if possible, as matomo handles obfuscation itself
$ip = $visitLocated->originalIpAddress ?? $visit->remoteAddr;
if ($ip !== null) {
$tracker->setIp($ip);
}
if ($visit->isOrphan()) {
$tracker->setCustomTrackingParameter('orphan', 'true');
}
// Send the short URL title or an empty document title to avoid different actions to be created by matomo
$tracker->doTrackPageView($visit->shortUrl?->title() ?? '');
$this->visitSender->sendVisit($visit, $visitLocated->originalIpAddress);
} catch (Throwable $e) {
// Capture all exceptions to make sure this does not interfere with the regular execution
$this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]);
}
}
public function resolveUrlToTrack(Visit $visit): string
{
$shortUrl = $visit->shortUrl;
if ($shortUrl === null) {
return $visit->visitedUrl ?? '';
}
return $this->shortUrlStringifier->stringify($shortUrl);
}
}

View file

@ -139,7 +139,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
$importedVisits = 0;
foreach ($iterable as $importedOrphanVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentOrphanVisit?->getDate()->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) {
if ($mostRecentOrphanVisit?->date->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) {
continue;
}

View file

@ -7,11 +7,11 @@ namespace Shlinkio\Shlink\Core\Matomo;
use MatomoTracker;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
readonly class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
{
public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds
public function __construct(private readonly MatomoOptions $options)
public function __construct(private MatomoOptions $options)
{
}

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface;
use Throwable;
readonly class MatomoVisitSender implements MatomoVisitSenderInterface
{
public function __construct(
private MatomoTrackerBuilderInterface $trackerBuilder,
private ShortUrlStringifier $shortUrlStringifier,
private VisitIterationRepositoryInterface $visitIterationRepository,
) {
}
/**
* Sends all visits in provided date range to matomo, and returns the amount of affected visits
*/
public function sendVisitsInDateRange(
DateRange $dateRange,
VisitSendingProgressTrackerInterface|null $progressTracker = null,
): SendVisitsResult {
$visitsIterator = $this->visitIterationRepository->findAllVisits($dateRange);
$successfulVisits = 0;
$failedVisits = 0;
foreach ($visitsIterator as $index => $visit) {
try {
$this->sendVisit($visit);
$progressTracker?->success($index);
$successfulVisits++;
} catch (Throwable $e) {
$progressTracker?->error($index, $e);
$failedVisits++;
}
}
return new SendVisitsResult($successfulVisits, $failedVisits);
}
public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void
{
$tracker = $this->trackerBuilder->buildMatomoTracker();
$tracker
->setUrl($this->resolveUrlToTrack($visit))
->setCustomTrackingParameter('type', $visit->type->value)
->setUserAgent($visit->userAgent)
->setUrlReferrer($visit->referer)
->setForceVisitDateTime($visit->date->setTimezone('UTC')->toDateTimeString());
$location = $visit->getVisitLocation();
if ($location !== null) {
$tracker
->setCity($location->cityName)
->setCountry($location->countryName)
->setLatitude($location->latitude)
->setLongitude($location->longitude);
}
// Set not obfuscated IP if possible, as matomo handles obfuscation itself
$ip = $originalIpAddress ?? $visit->remoteAddr;
if ($ip !== null) {
$tracker->setIp($ip);
}
if ($visit->isOrphan()) {
$tracker->setCustomTrackingParameter('orphan', 'true');
}
// Send the short URL title or an empty document title to avoid different actions to be created by matomo
$tracker->doTrackPageView($visit->shortUrl?->title() ?? '');
}
private function resolveUrlToTrack(Visit $visit): string
{
$shortUrl = $visit->shortUrl;
if ($shortUrl === null) {
return $visit->visitedUrl ?? '';
}
return $this->shortUrlStringifier->stringify($shortUrl);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\Model\SendVisitsResult;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
interface MatomoVisitSenderInterface
{
/**
* Sends all visits in provided date range to matomo, and returns the amount of affected visits
*/
public function sendVisitsInDateRange(
DateRange $dateRange,
VisitSendingProgressTrackerInterface|null $progressTracker = null,
): SendVisitsResult;
public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void;
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo\Model;
use Countable;
final readonly class SendVisitsResult implements Countable
{
/**
* @param int<0, max> $successfulVisits
* @param int<0, max> $failedVisits
*/
public function __construct(public int $successfulVisits = 0, public int $failedVisits = 0)
{
}
public function hasSuccesses(): bool
{
return $this->successfulVisits > 0;
}
public function hasFailures(): bool
{
return $this->failedVisits > 0;
}
public function count(): int
{
return $this->successfulVisits + $this->failedVisits;
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use Throwable;
interface VisitSendingProgressTrackerInterface
{
public function success(int $index): void;
public function error(int $index, Throwable $e): void;
}

View file

@ -209,7 +209,7 @@ class ShortUrl extends AbstractEntity
->setMaxResults(1);
$visit = $this->visits->matching($criteria)->last();
return $visit instanceof Visit ? $visit->getDate() : null;
return $visit instanceof Visit ? $visit->date : null;
}
/**

View file

@ -2,6 +2,9 @@
namespace Shlinkio\Shlink\Core\Util;
use Fig\Http\Message\RequestMethodInterface;
use Mezzio\Router\Route;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
enum RedirectStatus: int
@ -16,8 +19,13 @@ enum RedirectStatus: int
return contains($this, [self::STATUS_301, self::STATUS_308]);
}
public function isLegacyStatus(): bool
/**
* @return array<RequestMethodInterface::METHOD_*>|Route::HTTP_METHOD_ANY
*/
public function allowedHttpMethods(): array|null
{
return contains($this, [self::STATUS_301, self::STATUS_302]);
return contains($this, [self::STATUS_301, self::STATUS_302])
? [RequestMethodInterface::METHOD_GET]
: Route::HTTP_METHOD_ANY;
}
}

View file

@ -29,8 +29,7 @@ class Visit extends AbstractEntity implements JsonSerializable
public readonly ?string $remoteAddr = null,
public readonly ?string $visitedUrl = null,
private ?VisitLocation $visitLocation = null,
// TODO Make public readonly once VisitRepositoryTest does not try to set it
private Chronos $date = new Chronos(),
public readonly Chronos $date = new Chronos(),
) {
}
@ -147,14 +146,6 @@ class Visit extends AbstractEntity implements JsonSerializable
return $this->type;
}
/**
* @internal
*/
public function getDate(): Chronos
{
return $this->date;
}
public function jsonSerialize(): array
{
$base = [

View file

@ -8,14 +8,14 @@ use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class VisitLocator implements VisitLocatorInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly VisitLocationRepositoryInterface $repo,
private readonly VisitIterationRepositoryInterface $repo,
) {
}

View file

@ -6,9 +6,14 @@ namespace Shlinkio\Shlink\Core\Visit\Repository;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
class VisitLocationRepository extends EntitySpecificationRepository implements VisitLocationRepositoryInterface
/**
* Allows iterating large amounts of visits in a memory-efficient way, to use in batch processes
*/
class VisitIterationRepository extends EntitySpecificationRepository implements VisitIterationRepositoryInterface
{
/**
* @return iterable<Visit>
@ -42,9 +47,18 @@ class VisitLocationRepository extends EntitySpecificationRepository implements V
/**
* @return iterable<Visit>
*/
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
{
$qb = $this->createQueryBuilder('v');
if ($dateRange?->startDate !== null) {
$qb->andWhere($qb->expr()->gte('v.date', ':since'))
->setParameter('since', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
if ($dateRange?->endDate !== null) {
$qb->andWhere($qb->expr()->lte('v.date', ':until'))
->setParameter('until', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME);
}
return $this->visitsIterableForQuery($qb, $blockSize);
}

View file

@ -4,9 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Repository;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
interface VisitLocationRepositoryInterface
interface VisitIterationRepositoryInterface
{
public const DEFAULT_BLOCK_SIZE = 10000;
@ -23,5 +24,5 @@ interface VisitLocationRepositoryInterface
/**
* @return iterable<Visit>
*/
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
}

View file

@ -4,27 +4,29 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
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\Repository\VisitLocationRepository;
use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepository;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_map;
use function range;
class VisitLocationRepositoryTest extends DatabaseTestCase
class VisitIterationRepositoryTest extends DatabaseTestCase
{
private VisitLocationRepository $repo;
private VisitIterationRepository $repo;
protected function setUp(): void
{
$em = $this->getEntityManager();
$this->repo = new VisitLocationRepository($em, $em->getClassMetadata(Visit::class));
$this->repo = new VisitIterationRepository($em, $em->getClassMetadata(Visit::class));
}
#[Test, DataProvider('provideBlockSize')]
@ -33,7 +35,9 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
$shortUrl = ShortUrl::createFake();
$this->getEntityManager()->persist($shortUrl);
$unmodifiedDate = Chronos::now();
for ($i = 0; $i < 6; $i++) {
Chronos::setTestNow($unmodifiedDate->subDays($i)); // Enforce a different day for every visit
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
if ($i >= 2) {
@ -44,15 +48,34 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($visit);
}
Chronos::setTestNow();
$this->getEntityManager()->flush();
$withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize);
$unlocated = $this->repo->findUnlocatedVisits($blockSize);
$all = $this->repo->findAllVisits($blockSize);
$all = $this->repo->findAllVisits(blockSize: $blockSize);
$lastThreeDays = $this->repo->findAllVisits(
dateRange: DateRange::since(Chronos::now()->subDays(2)->startOfDay()),
blockSize: $blockSize,
);
$firstTwoDays = $this->repo->findAllVisits(
dateRange: DateRange::until(Chronos::now()->subDays(4)->endOfDay()),
blockSize: $blockSize,
);
$daysInBetween = $this->repo->findAllVisits(
dateRange: DateRange::between(
startDate: Chronos::now()->subDays(5)->startOfDay(),
endDate: Chronos::now()->subDays(2)->endOfDay(),
),
blockSize: $blockSize,
);
self::assertCount(2, [...$unlocated]);
self::assertCount(4, [...$withEmptyLocation]);
self::assertCount(6, [...$all]);
self::assertCount(3, [...$lastThreeDays]);
self::assertCount(2, [...$firstTwoDays]);
self::assertCount(4, [...$daysInBetween]);
}
public static function provideBlockSize(): iterable

View file

@ -6,7 +6,6 @@ namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use ReflectionObject;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@ -371,15 +370,15 @@ class VisitRepositoryTest extends DatabaseTestCase
$botsCount = 3;
for ($i = 0; $i < 6; $i++) {
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()),
fn () => Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forRegularNotFound(Visitor::emptyInstance()),
fn () => Visit::forRegularNotFound(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
@ -429,15 +428,15 @@ class VisitRepositoryTest extends DatabaseTestCase
for ($i = 0; $i < 6; $i++) {
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forBasePath(Visitor::emptyInstance()),
fn () => Visit::forBasePath(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forInvalidShortUrl(Visitor::emptyInstance()),
fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
Visit::forRegularNotFound(Visitor::emptyInstance()),
fn () => Visit::forRegularNotFound(Visitor::emptyInstance()),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
}
@ -566,7 +565,7 @@ class VisitRepositoryTest extends DatabaseTestCase
{
for ($i = 0; $i < $amount; $i++) {
$visit = $this->setDateOnVisit(
Visit::forValidShortUrl(
fn () => Visit::forValidShortUrl(
$shortUrl,
$botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(),
),
@ -578,12 +577,14 @@ class VisitRepositoryTest extends DatabaseTestCase
}
}
private function setDateOnVisit(Visit $visit, Chronos $date): Visit
/**
* @param callable(): Visit $createVisit
*/
private function setDateOnVisit(callable $createVisit, Chronos $date): Visit
{
$ref = new ReflectionObject($visit);
$dateProp = $ref->getProperty('date');
$dateProp->setAccessible(true);
$dateProp->setValue($visit, $date);
Chronos::setTestNow($date);
$visit = $createVisit();
Chronos::setTestNow();
return $visit;
}

View file

@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\Matomo;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use MatomoTracker;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@ -15,34 +14,28 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class SendVisitToMatomoTest extends TestCase
{
private MockObject & EntityManagerInterface $em;
private MockObject & LoggerInterface $logger;
private MockObject & MatomoTrackerBuilderInterface $trackerBuilder;
private MockObject & MatomoVisitSenderInterface $visitSender;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class);
$this->visitSender = $this->createMock(MatomoVisitSenderInterface::class);
}
#[Test]
public function visitIsNotSentWhenMatomoIsDisabled(): void
{
$this->em->expects($this->never())->method('find');
$this->trackerBuilder->expects($this->never())->method('buildMatomoTracker');
$this->visitSender->expects($this->never())->method('sendVisit');
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
@ -53,7 +46,7 @@ class SendVisitToMatomoTest extends TestCase
public function visitIsNotSentWhenItDoesNotExist(): void
{
$this->em->expects($this->once())->method('find')->willReturn(null);
$this->trackerBuilder->expects($this->never())->method('buildMatomoTracker');
$this->visitSender->expects($this->never())->method('sendVisit');
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->once())->method('warning')->with(
'Tried to send visit with id "{visitId}" to matomo, but it does not exist.',
@ -63,97 +56,24 @@ class SendVisitToMatomoTest extends TestCase
($this->listener())(new VisitLocated('123'));
}
#[Test, DataProvider('provideTrackerMethods')]
public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void
#[Test, DataProvider('provideOriginalIpAddress')]
public function visitIsSentWhenItExists(?string $originalIpAddress): void
{
$visitId = '123';
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? '');
if ($visit->isOrphan()) {
$tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([
['type', $visit->type->value, $tracker],
['orphan', 'true', $tracker],
]);
} else {
$tracker->expects($this->once())->method('setCustomTrackingParameter')->with(
'type',
$visit->type->value,
)->willReturn($tracker);
}
foreach ($invokedMethods as $invokedMethod) {
$tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker);
}
$visit = Visit::forBasePath(Visitor::emptyInstance());
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->visitSender->expects($this->once())->method('sendVisit')->with($visit, $originalIpAddress);
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
($this->listener())(new VisitLocated($visitId, $originalIpAddress));
}
public static function provideTrackerMethods(): iterable
public static function provideOriginalIpAddress(): iterable
{
yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []];
yield 'located regular visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance())
->locate(VisitLocation::fromGeolocation(new Location(
countryCode: 'countryCode',
countryName: 'countryName',
regionName: 'regionName',
city: 'city',
latitude: 123,
longitude: 123,
timeZone: 'timeZone',
))),
'1.2.3.4',
['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'],
];
yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']];
}
#[Test, DataProvider('provideUrlsToTrack')]
public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void
{
$visitId = '123';
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView');
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->logger->expects($this->never())->method('error');
$this->logger->expects($this->never())->method('warning');
($this->listener())(new VisitLocated($visitId));
}
public static function provideUrlsToTrack(): iterable
{
yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), ''];
yield 'orphan visit with visited URL' => [
Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')),
'https://s.test/foo',
];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::create(
ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://shlink.io',
ShortUrlInputFilter::CUSTOM_SLUG => 'bar',
]),
), Visitor::emptyInstance()),
'http://s2.test/bar',
];
yield 'no original IP address' => [null];
yield 'original IP address' => ['1.2.3.4'];
}
#[Test]
@ -165,7 +85,7 @@ class SendVisitToMatomoTest extends TestCase
$this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn(
$this->createMock(Visit::class),
);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willThrowException($e);
$this->visitSender->expects($this->once())->method('sendVisit')->willThrowException($e);
$this->logger->expects($this->never())->method('warning');
$this->logger->expects($this->once())->method('error')->with(
'An error occurred while trying to send visit to Matomo. {e}',
@ -180,9 +100,8 @@ class SendVisitToMatomoTest extends TestCase
return new SendVisitToMatomo(
$this->em,
$this->logger,
new ShortUrlStringifier(['hostname' => 's2.test']),
new MatomoOptions(enabled: $enabled),
$this->trackerBuilder,
$this->visitSender,
);
}
}

View file

@ -76,7 +76,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
'referer' => '',
'userAgent' => '',
'visitLocation' => null,
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'potentialBot' => false,
'visitedUrl' => '',
],
@ -100,7 +100,7 @@ class PublishingUpdatesGeneratorTest extends TestCase
'referer' => '',
'userAgent' => '',
'visitLocation' => null,
'date' => $orphanVisit->getDate()->toAtomString(),
'date' => $orphanVisit->date->toAtomString(),
'potentialBot' => false,
'visitedUrl' => $orphanVisit->visitedUrl,
'type' => $orphanVisit->type->value,

View file

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Matomo;
use Exception;
use MatomoTracker;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSender;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
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\Repository\VisitIterationRepositoryInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
class MatomoVisitSenderTest extends TestCase
{
private MockObject & MatomoTrackerBuilderInterface $trackerBuilder;
private MockObject & VisitIterationRepositoryInterface $visitIterationRepository;
private MatomoVisitSender $visitSender;
protected function setUp(): void
{
$this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class);
$this->visitIterationRepository = $this->createMock(VisitIterationRepositoryInterface::class);
$this->visitSender = new MatomoVisitSender(
$this->trackerBuilder,
new ShortUrlStringifier(['hostname' => 's2.test']),
$this->visitIterationRepository,
);
}
#[Test, DataProvider('provideTrackerMethods')]
public function visitIsSentToMatomo(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void
{
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView')->with($visit->shortUrl?->title() ?? '');
$tracker->expects($this->once())->method('setForceVisitDateTime')->with(
$visit->date->setTimezone('UTC')->toDateTimeString(),
);
if ($visit->isOrphan()) {
$tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([
['type', $visit->type->value, $tracker],
['orphan', 'true', $tracker],
]);
} else {
$tracker->expects($this->once())->method('setCustomTrackingParameter')->with(
'type',
$visit->type->value,
)->willReturn($tracker);
}
foreach ($invokedMethods as $invokedMethod) {
$tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker);
}
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->visitSender->sendVisit($visit, $originalIpAddress);
}
public static function provideTrackerMethods(): iterable
{
yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []];
yield 'located regular visit' => [
Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance())
->locate(VisitLocation::fromGeolocation(new Location(
countryCode: 'countryCode',
countryName: 'countryName',
regionName: 'regionName',
city: 'city',
latitude: 123,
longitude: 123,
timeZone: 'timeZone',
))),
'1.2.3.4',
['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'],
];
yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']];
}
#[Test, DataProvider('provideUrlsToTrack')]
public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void
{
$tracker = $this->createMock(MatomoTracker::class);
$tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker);
$tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker);
$tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker);
$tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker);
$tracker->expects($this->once())->method('doTrackPageView');
$tracker->expects($this->once())->method('setForceVisitDateTime')->with(
$visit->date->setTimezone('UTC')->toDateTimeString(),
);
$this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker);
$this->visitSender->sendVisit($visit);
}
public static function provideUrlsToTrack(): iterable
{
yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), ''];
yield 'orphan visit with visited URL' => [
Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')),
'https://s.test/foo',
];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::create(
ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://shlink.io',
ShortUrlInputFilter::CUSTOM_SLUG => 'bar',
]),
), Visitor::emptyInstance()),
'http://s2.test/bar',
];
}
#[Test]
public function multipleVisitsCanBeSent(): void
{
$dateRange = DateRange::allTime();
$visitor = Visitor::emptyInstance();
$bot = Visitor::botInstance();
$this->visitIterationRepository->expects($this->once())->method('findAllVisits')->with($dateRange)->willReturn([
Visit::forBasePath($bot),
Visit::forValidShortUrl(ShortUrl::createFake(), $visitor),
Visit::forInvalidShortUrl($visitor),
]);
$tracker = $this->createMock(MatomoTracker::class);
$tracker->method('setUrl')->willReturn($tracker);
$tracker->method('setUserAgent')->willReturn($tracker);
$tracker->method('setUrlReferrer')->willReturn($tracker);
$tracker->method('setCustomTrackingParameter')->willReturn($tracker);
$callCount = 0;
$this->trackerBuilder->expects($this->exactly(3))->method('buildMatomoTracker')->willReturnCallback(
function () use (&$callCount, $tracker) {
$callCount++;
if ($callCount === 2) {
throw new Exception('Error');
}
return $tracker;
},
);
$result = $this->visitSender->sendVisitsInDateRange($dateRange);
self::assertEquals(2, $result->successfulVisits);
self::assertEquals(1, $result->failedVisits);
self::assertCount(3, $result);
self::assertTrue($result->hasSuccesses());
self::assertTrue($result->hasFailures());
}
}

View file

@ -26,7 +26,7 @@ class VisitTest extends TestCase
self::assertEquals([
'referer' => 'some site',
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => $userAgent,
'visitLocation' => null,
'potentialBot' => $expectedToBePotentialBot,
@ -58,7 +58,7 @@ class VisitTest extends TestCase
$visit = Visit::forBasePath(Visitor::emptyInstance()),
[
'referer' => '',
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => '',
'visitLocation' => null,
'potentialBot' => false,
@ -74,7 +74,7 @@ class VisitTest extends TestCase
)),
[
'referer' => 'bar',
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => 'foo',
'visitLocation' => null,
'potentialBot' => false,
@ -92,7 +92,7 @@ class VisitTest extends TestCase
)->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())),
[
'referer' => 'referer',
'date' => $visit->getDate()->toAtomString(),
'date' => $visit->date->toAtomString(),
'userAgent' => 'user-agent',
'visitLocation' => $location,
'potentialBot' => false,

View file

@ -17,7 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitGeolocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use function array_map;
@ -30,12 +30,12 @@ class VisitLocatorTest extends TestCase
{
private VisitLocator $visitService;
private MockObject & EntityManager $em;
private MockObject & VisitLocationRepositoryInterface $repo;
private MockObject & VisitIterationRepositoryInterface $repo;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManager::class);
$this->repo = $this->createMock(VisitLocationRepositoryInterface::class);
$this->repo = $this->createMock(VisitIterationRepositoryInterface::class);
$this->visitService = new VisitLocator($this->em, $this->repo);
}

View file

@ -8,7 +8,6 @@ use Cake\Chronos\Chronos;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use ReflectionObject;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
@ -50,27 +49,31 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
);
$manager->persist($this->setVisitDate(
Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')),
fn () => Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')),
'2020-01-01',
));
$manager->persist($this->setVisitDate(
Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', '')),
fn () => Visit::forRegularNotFound(
new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', ''),
),
'2020-02-01',
));
$manager->persist($this->setVisitDate(
Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')),
fn () => Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')),
'2020-03-01',
));
$manager->flush();
}
private function setVisitDate(Visit $visit, string $date): Visit
/**
* @param callable(): Visit $createVisit
*/
private function setVisitDate(callable $createVisit, string $date): Visit
{
$ref = new ReflectionObject($visit);
$dateProp = $ref->getProperty('date');
$dateProp->setAccessible(true);
$dateProp->setValue($visit, Chronos::parse($date));
Chronos::setTestNow($date);
$visit = $createVisit();
Chronos::setTestNow();
return $visit;
}