Merge pull request #2503 from acelaya-forks/domain-visits-filter

Allow tags, orphan and non-orphan visits lists to be filtered by domain
This commit is contained in:
Alejandro Celaya 2025-11-01 10:02:32 +01:00 committed by GitHub
commit 628fb9ebb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 482 additions and 256 deletions

View File

@ -20,9 +20,9 @@
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
"doctrine/dbal": "^4.2",
"doctrine/migrations": "^3.8",
"doctrine/orm": "^3.3",
"doctrine/dbal": "^4.3",
"doctrine/migrations": "^3.9",
"doctrine/orm": "^3.5",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0.5",
"friendsofphp/proxy-manager-lts": "^1.0",

View File

@ -64,6 +64,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -55,6 +55,10 @@
"type": "string",
"enum": ["true"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -65,6 +65,10 @@
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
},
{
"$ref": "../parameters/domain.json",
"description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [

View File

@ -5,24 +5,34 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
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\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'tag:visits';
private readonly DomainOption $domainOption;
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -4,23 +4,33 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
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\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:non-orphan';
private readonly DomainOption $domainOption;
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
));
}
/**

View File

@ -4,11 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
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;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@ -19,6 +22,17 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:orphan';
private readonly DomainOption $domainOption;
public function __construct(VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
}
protected function configure(): void
{
$this
@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
$rawType = $input->getOption('type');
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
type: $type,
));
}
/**

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class DomainOption
{
private const string NAME = 'domain';
public function __construct(Command $command, string $description)
{
$command->addOption(
name: self::NAME,
shortcut: 'd',
mode: InputOption::VALUE_REQUIRED,
description: $description,
);
}
public function get(InputInterface $input): string|null
{
return $input->getOption(self::NAME);
}
}

View File

@ -168,7 +168,7 @@ return [
],
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
Visit\VisitsStatsHelper::class => ['em'],
Visit\VisitsStatsHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
ShortUrl\DeleteShortUrlService::class => [
'em',

View File

@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
abstract class AbstractInfinitePaginableListParams
{
private const FIRST_PAGE = 1;
private const int FIRST_PAGE = 1;
public readonly int $page;
public readonly int $itemsPerPage;

View File

@ -159,13 +159,11 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
$qb->andWhere($qb->expr()->notIn('s.id', $subQb->getDQL()));
}
if ($filtering->domain !== null) {
if ($filtering->domain === Domain::DEFAULT_AUTHORITY) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} else {
$qb->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $filtering->domain);
}
if ($filtering->domain === Domain::DEFAULT_AUTHORITY) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} elseif ($filtering->domain !== null) {
$qb->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $filtering->domain);
}
if ($filtering->excludeMaxVisitsReached) {

View File

@ -9,20 +9,22 @@ use ValueError;
use function Shlinkio\Shlink\Core\enumToString;
use function sprintf;
final class OrphanVisitsParams extends VisitsParams
final class OrphanVisitsParams extends WithDomainVisitsParams
{
public function __construct(
DateRange|null $dateRange = null,
int|null $page = null,
int|null $itemsPerPage = null,
bool $excludeBots = false,
string|null $domain = null,
public readonly OrphanVisitType|null $type = null,
) {
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots);
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots, $domain);
}
public static function fromVisitsParamsAndRawData(VisitsParams $visitsParams, array $query): self
public static function fromRawData(array $query): self
{
$visitsParams = WithDomainVisitsParams::fromRawData($query);
$type = $query['type'] ?? null;
return new self(
@ -30,6 +32,7 @@ final class OrphanVisitsParams extends VisitsParams
page: $visitsParams->page,
itemsPerPage: $visitsParams->itemsPerPage,
excludeBots: $visitsParams->excludeBots,
domain: $visitsParams->domain,
type: $type !== null ? self::parseType($type) : null,
);
}

View File

@ -0,0 +1,31 @@
<?php
namespace Shlinkio\Shlink\Core\Visit\Model;
use Shlinkio\Shlink\Common\Util\DateRange;
class WithDomainVisitsParams extends VisitsParams
{
public function __construct(
DateRange|null $dateRange = null,
int|null $page = null,
int|null $itemsPerPage = null,
bool $excludeBots = false,
public readonly string|null $domain = null,
) {
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots);
}
public static function fromRawData(array $query): self
{
$visitsParams = VisitsParams::fromRawData($query);
return new self(
dateRange: $visitsParams->dateRange,
page: $visitsParams->page,
itemsPerPage: $visitsParams->itemsPerPage,
excludeBots: $visitsParams->excludeBots,
domain: $query['domain'] ?? null,
);
}
}

View File

@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -17,26 +17,28 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda
{
public function __construct(
private readonly VisitRepositoryInterface $repo,
private readonly VisitsParams $params,
private readonly WithDomainVisitsParams $params,
private readonly ApiKey|null $apiKey,
) {
}
protected function doCount(): int
{
return $this->repo->countNonOrphanVisits(new VisitsCountFiltering(
return $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
$this->params->dateRange,
$this->params->excludeBots,
$this->apiKey,
$this->params->domain,
));
}
public function getSlice(int $offset, int $length): iterable
{
return $this->repo->findNonOrphanVisits(new VisitsListFiltering(
return $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
$this->params->dateRange,
$this->params->excludeBots,
$this->apiKey,
$this->params->domain,
$length,
$offset,
));

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
@ -19,6 +20,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
private readonly VisitRepositoryInterface $repo,
private readonly OrphanVisitsParams $params,
private readonly ApiKey|null $apiKey,
private readonly UrlShortenerOptions $options,
) {
}
@ -28,7 +30,9 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
dateRange: $this->params->dateRange,
excludeBots: $this->params->excludeBots,
apiKey: $this->apiKey,
domain: $this->params->domain,
type: $this->params->type,
defaultDomain: $this->options->defaultDomain,
));
}
@ -38,7 +42,9 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
dateRange: $this->params->dateRange,
excludeBots: $this->params->excludeBots,
apiKey: $this->apiKey,
domain: $this->params->domain,
type: $this->params->type,
defaultDomain: $this->options->defaultDomain,
limit: $length,
offset: $offset,
));

View File

@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -18,7 +18,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
public function __construct(
private readonly VisitRepositoryInterface $visitRepository,
private readonly string $tag,
private readonly VisitsParams $params,
private readonly WithDomainVisitsParams $params,
private readonly ApiKey|null $apiKey,
) {
}
@ -27,10 +27,11 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
return $this->visitRepository->findVisitsByTag(
$this->tag,
new VisitsListFiltering(
new WithDomainVisitsListFiltering(
$this->params->dateRange,
$this->params->excludeBots,
$this->apiKey,
$this->params->domain,
$length,
$offset,
),
@ -41,10 +42,11 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
return $this->visitRepository->countVisitsByTag(
$this->tag,
new VisitsCountFiltering(
new WithDomainVisitsCountFiltering(
$this->params->dateRange,
$this->params->excludeBots,
$this->apiKey,
$this->params->domain,
),
);
}

View File

@ -8,14 +8,16 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class OrphanVisitsCountFiltering extends VisitsCountFiltering
class OrphanVisitsCountFiltering extends WithDomainVisitsCountFiltering
{
public function __construct(
DateRange|null $dateRange = null,
bool $excludeBots = false,
ApiKey|null $apiKey = null,
string|null $domain = null,
public readonly OrphanVisitType|null $type = null,
public readonly string $defaultDomain = '',
) {
parent::__construct($dateRange, $excludeBots, $apiKey);
parent::__construct($dateRange, $excludeBots, $apiKey, $domain);
}
}

View File

@ -14,10 +14,12 @@ final class OrphanVisitsListFiltering extends OrphanVisitsCountFiltering
DateRange|null $dateRange = null,
bool $excludeBots = false,
ApiKey|null $apiKey = null,
string|null $domain = null,
OrphanVisitType|null $type = null,
string $defaultDomain = '',
public readonly int|null $limit = null,
public readonly int|null $offset = null,
) {
parent::__construct($dateRange, $excludeBots, $apiKey, $type);
parent::__construct($dateRange, $excludeBots, $apiKey, $domain, $type, $defaultDomain);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Persistence;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class WithDomainVisitsCountFiltering extends VisitsCountFiltering
{
public function __construct(
DateRange|null $dateRange = null,
bool $excludeBots = false,
ApiKey|null $apiKey = null,
public readonly string|null $domain = null,
) {
parent::__construct($dateRange, $excludeBots, $apiKey);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Persistence;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
final class WithDomainVisitsListFiltering extends WithDomainVisitsCountFiltering
{
public function __construct(
DateRange|null $dateRange = null,
bool $excludeBots = false,
ApiKey|null $apiKey = null,
string|null $domain = null,
public readonly int|null $limit = null,
public readonly int|null $offset = null,
) {
parent::__construct($dateRange, $excludeBots, $apiKey, $domain);
}
}

View File

@ -18,8 +18,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits;
use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -69,13 +69,13 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $qb;
}
public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array
public function findVisitsByTag(string $tag, WithDomainVisitsListFiltering $filtering): array
{
$qb = $this->createVisitsByTagQueryBuilder($tag, $filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int
public function countVisitsByTag(string $tag, WithDomainVisitsCountFiltering $filtering): int
{
$qb = $this->createVisitsByTagQueryBuilder($tag, $filtering);
$qb->select('COUNT(v.id)');
@ -83,19 +83,29 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByTagQueryBuilder(string $tag, VisitsCountFiltering $filtering): QueryBuilder
private function createVisitsByTagQueryBuilder(string $tag, WithDomainVisitsCountFiltering $filtering): QueryBuilder
{
$conn = $this->getEntityManager()->getConnection();
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's')
->join('s.tags', 't')
->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag)));
->where($qb->expr()->eq('t.name', $conn->quote($tag)));
if ($filtering->excludeBots) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$domain = $filtering->domain;
if ($domain === Domain::DEFAULT_AUTHORITY) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} elseif ($domain !== null) {
$qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', $conn->quote($domain)));
}
$this->applyDatesInline($qb, $filtering->dateRange);
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v');
@ -149,15 +159,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return [];
}
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
if ($filtering->type) {
$conn = $this->getEntityManager()->getConnection();
$qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value)));
}
$qb = $this->createOrphanVisitsQueryBuilder($filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
@ -167,36 +169,78 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return 0;
}
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering));
$qb = $this->createOrphanVisitsQueryBuilder($filtering);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createOrphanVisitsQueryBuilder(OrphanVisitsCountFiltering $filtering): QueryBuilder
{
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
$conn = $this->getEntityManager()->getConnection();
if ($filtering->type) {
$qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value)));
}
$domain = $filtering->domain;
$domain = $domain === Domain::DEFAULT_AUTHORITY ? $filtering->defaultDomain : $domain;
if ($domain !== null) {
$qb->andWhere($qb->expr()->like('v.visitedUrl', $conn->quote('%' . $domain . '%')));
}
return $qb;
}
/**
* @return Visit[]
*/
public function findNonOrphanVisits(VisitsListFiltering $filtering): array
public function findNonOrphanVisits(WithDomainVisitsListFiltering $filtering): array
{
$qb = $this->createNonOrphanVisitsQueryBuilder($filtering);
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
}
public function countNonOrphanVisits(WithDomainVisitsCountFiltering $filtering): int
{
$qb = $this->createNonOrphanVisitsQueryBuilder($filtering);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createNonOrphanVisitsQueryBuilder(WithDomainVisitsCountFiltering $filtering): QueryBuilder
{
$conn = $this->getEntityManager()->getConnection();
$qb = $this->createAllVisitsQueryBuilder($filtering);
$qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
$apiKey = $filtering->apiKey;
if (ApiKey::isShortUrlRestricted($apiKey)) {
$domain = $filtering->domain;
if (ApiKey::isShortUrlRestricted($apiKey) || $domain !== null) {
$qb->join('v.shortUrl', 's');
}
if ($domain === Domain::DEFAULT_AUTHORITY) {
$qb->andWhere($qb->expr()->isNull('s.domain'));
} elseif ($domain !== null) {
$qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', $conn->quote($domain)));
}
$this->applySpecification($qb, $apiKey?->inlinedSpec(), 'v');
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
return $qb;
}
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int
private function createAllVisitsQueryBuilder(VisitsCountFiltering $filtering): QueryBuilder
{
return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering));
}
private function createAllVisitsQueryBuilder(VisitsListFiltering|OrphanVisitsListFiltering $filtering): QueryBuilder
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
// Since they are not provided by the caller, it's reasonably safe
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
// Since they are not provided by the caller, it's reasonably safe.
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v');

View File

@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
/**
* @extends ObjectRepository<Visit>
@ -28,9 +30,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
/**
* @return Visit[]
*/
public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array;
public function findVisitsByTag(string $tag, WithDomainVisitsListFiltering $filtering): array;
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int;
public function countVisitsByTag(string $tag, WithDomainVisitsCountFiltering $filtering): int;
/**
* @return Visit[]
@ -49,9 +51,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
/**
* @return Visit[]
*/
public function findNonOrphanVisits(VisitsListFiltering $filtering): array;
public function findNonOrphanVisits(WithDomainVisitsListFiltering $filtering): array;
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int;
public function countNonOrphanVisits(WithDomainVisitsCountFiltering $filtering): int;
public function findMostRecentOrphanVisit(): Visit|null;
}

View File

@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Spec;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Spec\InDateRange;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
class CountOfNonOrphanVisits extends BaseSpecification
{
public function __construct(private readonly VisitsCountFiltering $filtering)
{
parent::__construct();
}
protected function getSpec(): Specification
{
$conditions = [
Spec::isNotNull('shortUrl'),
new InDateRange($this->filtering->dateRange),
];
if ($this->filtering->excludeBots) {
$conditions[] = Spec::eq('potentialBot', false);
}
$apiKey = $this->filtering->apiKey;
if ($apiKey !== null) {
$conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl');
}
return Spec::countOf(Spec::andX(...$conditions));
}
}

View File

@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Spec;
use Happyr\DoctrineSpecification\Spec;
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Spec\InDateRange;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
class CountOfOrphanVisits extends BaseSpecification
{
public function __construct(private readonly OrphanVisitsCountFiltering $filtering)
{
parent::__construct();
}
protected function getSpec(): Specification
{
$conditions = [
Spec::isNull('shortUrl'),
new InDateRange($this->filtering->dateRange),
];
if ($this->filtering->excludeBots) {
$conditions[] = Spec::eq('potentialBot', false);
}
if ($this->filtering->type) {
$conditions[] = Spec::eq('type', $this->filtering->type->value);
}
return Spec::countOf(Spec::andX(...$conditions));
}
}

View File

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
@ -23,6 +24,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
@ -37,7 +39,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
{
public function __construct(private EntityManagerInterface $em)
public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $options)
{
}
@ -88,7 +90,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
/**
* @inheritDoc
*/
public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator
public function visitsForTag(string $tag, WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator
{
/** @var TagRepository $tagRepo */
$tagRepo = $this->em->getRepository(Tag::class);
@ -127,10 +129,13 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
/** @var VisitRepository $repo */
$repo = $this->em->getRepository(Visit::class);
return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
return $this->createPaginator(
new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey, $this->options),
$params,
);
}
public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator
public function nonOrphanVisits(WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator
{
/** @var VisitRepository $repo */
$repo = $this->em->getRepository(Visit::class);

View File

@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitsStatsHelperInterface
@ -33,7 +34,7 @@ interface VisitsStatsHelperInterface
* @return Paginator<Visit>
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator;
public function visitsForTag(string $tag, WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator;
/**
* @return Paginator<Visit>
@ -49,5 +50,5 @@ interface VisitsStatsHelperInterface
/**
* @return Paginator<Visit>
*/
public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator;
public function nonOrphanVisits(WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator;
}

View File

@ -22,6 +22,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository;
@ -187,13 +189,13 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->createShortUrlsAndVisits(false, [$foo]);
$this->getEntityManager()->flush();
self::assertCount(0, $this->repo->findVisitsByTag('invalid', new VisitsListFiltering()));
self::assertCount(18, $this->repo->findVisitsByTag($foo, new VisitsListFiltering()));
self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(null, true)));
self::assertCount(6, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(
self::assertCount(0, $this->repo->findVisitsByTag('invalid', new WithDomainVisitsListFiltering()));
self::assertCount(18, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering()));
self::assertCount(12, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(null, true)));
self::assertCount(6, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(
DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(
self::assertCount(12, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(
DateRange::since(Chronos::parse('2016-01-03')),
)));
}
@ -203,21 +205,40 @@ class VisitRepositoryTest extends DatabaseTestCase
{
$foo = 'foo';
$this->createShortUrlsAndVisits(false, [$foo]);
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => [$foo],
ShortUrlInputFilter::DOMAIN => 'foo.com',
]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
$this->createVisitsForShortUrl($shortUrl1, 6);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => [$foo],
]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 6);
$this->getEntityManager()->flush();
$this->createShortUrlsAndVisits(false, [$foo]);
$this->getEntityManager()->flush();
self::assertEquals(0, $this->repo->countVisitsByTag('invalid', new VisitsCountFiltering()));
self::assertEquals(12, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering()));
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(null, true)));
self::assertEquals(4, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(
self::assertEquals(0, $this->repo->countVisitsByTag('invalid', new WithDomainVisitsCountFiltering()));
self::assertEquals(12, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering()));
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
excludeBots: true,
)));
self::assertEquals(4, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
)));
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
DateRange::since(Chronos::parse('2016-01-03')),
)));
self::assertEquals(6, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
domain: 'foo.com',
)));
self::assertEquals(6, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
domain: Domain::DEFAULT_AUTHORITY,
)));
}
#[Test]
@ -318,13 +339,17 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering()));
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering()));
self::assertEquals(4 + 5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering()));
self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1)));
self::assertEquals(4, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(apiKey: $apiKey1)));
self::assertEquals(4, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1)));
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2)));
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
apiKey: $apiKey2,
)));
self::assertEquals(5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2)));
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $domainApiKey)));
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
apiKey: $domainApiKey,
)));
self::assertEquals(4 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(
apiKey: $domainApiKey,
)));
@ -334,21 +359,27 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertEquals(0, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering(
apiKey: $noOrphanVisitsApiKey,
)));
self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
self::assertEquals(4, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
Chronos::parse('2016-01-05')->startOfDay(),
))));
self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
self::assertEquals(2, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
Chronos::parse('2016-01-03')->startOfDay(),
), false, $apiKey1)));
self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
self::assertEquals(1, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
Chronos::parse('2016-01-07')->startOfDay(),
), false, $apiKey2)));
self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(
new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
new WithDomainVisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
));
self::assertEquals(3 + 5, $this->countRepo->countNonOrphanVisits(
new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
));
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(
new WithDomainVisitsCountFiltering(domain: $domain->authority),
));
self::assertEquals(5, $this->repo->countNonOrphanVisits(
new WithDomainVisitsCountFiltering(domain: Domain::DEFAULT_AUTHORITY),
));
self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering()));
self::assertEquals(4, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering()));
self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true)));
@ -374,11 +405,11 @@ class VisitRepositoryTest extends DatabaseTestCase
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
fn () => Visit::forInvalidShortUrl(Visitor::empty()),
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(visitedUrl: 'https://s.test/bar')),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
fn () => Visit::forRegularNotFound(Visitor::empty()),
fn () => Visit::forRegularNotFound(Visitor::fromParams(visitedUrl: 'https://example.com/foo?1=2')),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
@ -417,6 +448,11 @@ class VisitRepositoryTest extends DatabaseTestCase
type: OrphanVisitType::BASE_URL,
limit: 4,
)));
self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(domain: 'example.com')));
self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(
domain: Domain::DEFAULT_AUTHORITY,
defaultDomain: 's.test',
)));
}
#[Test]
@ -432,11 +468,11 @@ class VisitRepositoryTest extends DatabaseTestCase
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
fn () => Visit::forInvalidShortUrl(Visitor::empty()),
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(visitedUrl: 'https://s.test/foo/bar')),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
$this->getEntityManager()->persist($this->setDateOnVisit(
fn () => Visit::forRegularNotFound(Visitor::empty()),
fn () => Visit::forRegularNotFound(Visitor::fromParams(visitedUrl: 'https://example.com/foo/bar')),
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
));
}
@ -465,6 +501,11 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(
type: OrphanVisitType::REGULAR_404,
)));
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(domain: 'example.com')));
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(
domain: Domain::DEFAULT_AUTHORITY,
defaultDomain: 's.test',
)));
}
#[Test]
@ -479,31 +520,38 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering()));
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::allTime())));
self::assertCount(4, $this->repo->findNonOrphanVisits(new VisitsListFiltering(apiKey: $authoredApiKey)));
self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::since(
self::assertCount(21, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering()));
self::assertCount(21, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
DateRange::allTime(),
)));
self::assertCount(4, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
apiKey: $authoredApiKey,
)));
self::assertCount(7, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::since(
Chronos::parse('2016-01-05')->endOfDay(),
))));
self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::until(
self::assertCount(12, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::until(
Chronos::parse('2016-01-04')->endOfDay(),
))));
self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
self::assertCount(6, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
Chronos::parse('2016-01-03')->startOfDay(),
Chronos::parse('2016-01-04')->endOfDay(),
))));
self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
self::assertCount(13, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
Chronos::parse('2016-01-03')->startOfDay(),
Chronos::parse('2016-01-08')->endOfDay(),
))));
self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
self::assertCount(3, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
Chronos::parse('2016-01-03')->startOfDay(),
Chronos::parse('2016-01-08')->endOfDay(),
), limit: 10, offset: 10)));
self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(excludeBots: true)));
self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10)));
self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10, offset: 20)));
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 5, offset: 5)));
self::assertCount(15, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(excludeBots: true)));
self::assertCount(10, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(limit: 10)));
self::assertCount(1, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
limit: 10,
offset: 20,
)));
self::assertCount(5, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(limit: 5, offset: 5)));
}
#[Test]
@ -526,6 +574,7 @@ class VisitRepositoryTest extends DatabaseTestCase
/**
* @return array{string, string, ShortUrl}
* @fixme This method does too many things and is not intuitive. It should be removed or simplified
*/
private function createShortUrlsAndVisits(
bool|string $withDomain = true,
@ -558,6 +607,10 @@ class VisitRepositoryTest extends DatabaseTestCase
return [$shortCode, $domain, $shortUrl];
}
/**
* @param int $amount - How many visits in total. Defaults to 6
* @param int $botsAmount - How many of the visits should be bots. Defaults to 2
*/
private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6, int $botsAmount = 2): void
{
for ($i = 0; $i < $amount; $i++) {

View File

@ -10,10 +10,10 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -21,13 +21,13 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
{
private NonOrphanVisitsPaginatorAdapter $adapter;
private MockObject & VisitRepositoryInterface $repo;
private VisitsParams $params;
private WithDomainVisitsParams $params;
private ApiKey $apiKey;
protected function setUp(): void
{
$this->repo = $this->createMock(VisitRepositoryInterface::class);
$this->params = VisitsParams::fromRawData([]);
$this->params = WithDomainVisitsParams::fromRawData([]);
$this->apiKey = ApiKey::create();
$this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey);
@ -38,7 +38,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
{
$expectedCount = 5;
$this->repo->expects($this->once())->method('countNonOrphanVisits')->with(
new VisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey),
new WithDomainVisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey),
)->willReturn($expectedCount);
$result = $this->adapter->getNbResults();
@ -55,12 +55,12 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
{
$visitor = Visitor::empty();
$list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)];
$this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new VisitsListFiltering(
$this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new WithDomainVisitsListFiltering(
$this->params->dateRange,
$this->params->excludeBots,
$this->apiKey,
$limit,
$offset,
limit: $limit,
offset: $offset,
))->willReturn($list);
$result = $this->adapter->getSlice($offset, $limit);

View File

@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
@ -30,7 +31,12 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase
$this->params = new OrphanVisitsParams();
$this->apiKey = ApiKey::create();
$this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey);
$this->adapter = new OrphanVisitsPaginatorAdapter(
$this->repo,
$this->params,
$this->apiKey,
new UrlShortenerOptions(),
);
}
#[Test]

View File

@ -8,10 +8,10 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -33,7 +33,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter(null);
$this->repo->expects($this->exactly($count))->method('findVisitsByTag')->with(
'foo',
new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset),
new WithDomainVisitsListFiltering(DateRange::allTime(), limit: $limit, offset: $offset),
)->willReturn([]);
for ($i = 0; $i < $count; $i++) {
@ -49,7 +49,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter($apiKey);
$this->repo->expects($this->once())->method('countVisitsByTag')->with(
'foo',
new VisitsCountFiltering(DateRange::allTime(), false, $apiKey),
new WithDomainVisitsCountFiltering(DateRange::allTime(), apiKey: $apiKey),
)->willReturn(3);
for ($i = 0; $i < $count; $i++) {
@ -59,6 +59,6 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
private function createAdapter(ApiKey|null $apiKey): TagVisitsPaginatorAdapter
{
return new TagVisitsPaginatorAdapter($this->repo, 'foo', VisitsParams::fromRawData([]), $apiKey);
return new TagVisitsPaginatorAdapter($this->repo, 'foo', WithDomainVisitsParams::fromRawData([]), $apiKey);
}
}

View File

@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProviderExternal;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
@ -29,10 +30,12 @@ use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository;
@ -52,7 +55,7 @@ class VisitsStatsHelperTest extends TestCase
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->helper = new VisitsStatsHelper($this->em);
$this->helper = new VisitsStatsHelper($this->em, new UrlShortenerOptions());
}
#[Test, DataProvider('provideCounts')]
@ -147,7 +150,7 @@ class VisitsStatsHelperTest extends TestCase
$this->expectException(TagNotFoundException::class);
$this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
$this->helper->visitsForTag($tag, new WithDomainVisitsParams(), $apiKey);
}
#[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')]
@ -170,7 +173,7 @@ class VisitsStatsHelperTest extends TestCase
[Visit::class, $repo2],
]);
$paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
$paginator = $this->helper->visitsForTag($tag, new WithDomainVisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
}
@ -265,14 +268,14 @@ class VisitsStatsHelperTest extends TestCase
);
$repo = $this->createMock(VisitRepository::class);
$repo->expects($this->once())->method('countNonOrphanVisits')->with(
$this->isInstanceOf(VisitsCountFiltering::class),
$this->isInstanceOf(WithDOmainVisitsCountFiltering::class),
)->willReturn(count($list));
$repo->expects($this->once())->method('findNonOrphanVisits')->with(
$this->isInstanceOf(VisitsListFiltering::class),
$this->isInstanceOf(WithDOmainVisitsListFiltering::class),
)->willReturn($list);
$this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo);
$paginator = $this->helper->nonOrphanVisits(new VisitsParams());
$paginator = $this->helper->nonOrphanVisits(new WithDomainVisitsParams());
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
}

View File

@ -10,7 +10,6 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -26,9 +25,8 @@ abstract class AbstractListVisitsAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
$params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->getVisitsPaginator($request, $params, $apiKey);
$visits = $this->getVisitsPaginator($request, $apiKey);
return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]);
}
@ -36,9 +34,5 @@ abstract class AbstractListVisitsAction extends AbstractRestAction
/**
* @return Pagerfanta<Visit>
*/
abstract protected function getVisitsPaginator(
ServerRequestInterface $request,
VisitsParams $params,
ApiKey $apiKey,
): Pagerfanta;
abstract protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta;
}

View File

@ -23,8 +23,9 @@ class DomainVisitsAction extends AbstractListVisitsAction
parent::__construct($visitsHelper);
}
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
{
$params = VisitsParams::fromRawData($request->getQueryParams());
$domain = $this->resolveDomainParam($request);
return $this->visitsHelper->visitsForDomain($domain, $params, $apiKey);
}

View File

@ -6,18 +6,16 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
use Pagerfanta\Pagerfanta;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class NonOrphanVisitsAction extends AbstractListVisitsAction
{
protected const string ROUTE_PATH = '/visits/non-orphan';
protected function getVisitsPaginator(
ServerRequestInterface $request,
VisitsParams $params,
ApiKey $apiKey,
): Pagerfanta {
protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta
{
$params = WithDomainVisitsParams::fromRawData($request->getQueryParams());
return $this->visitsHelper->nonOrphanVisits($params, $apiKey);
}
}

View File

@ -7,19 +7,15 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
use Pagerfanta\Pagerfanta;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class OrphanVisitsAction extends AbstractListVisitsAction
{
protected const string ROUTE_PATH = '/visits/orphan';
protected function getVisitsPaginator(
ServerRequestInterface $request,
VisitsParams $params,
ApiKey $apiKey,
): Pagerfanta {
$orphanParams = OrphanVisitsParams::fromVisitsParamsAndRawData($params, $request->getQueryParams());
protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta
{
$orphanParams = OrphanVisitsParams::fromRawData($request->getQueryParams());
return $this->visitsHelper->orphanVisits($orphanParams, $apiKey);
}
}

View File

@ -14,8 +14,9 @@ class ShortUrlVisitsAction extends AbstractListVisitsAction
{
protected const string ROUTE_PATH = '/short-urls/{shortCode}/visits';
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
{
$params = VisitsParams::fromRawData($request->getQueryParams());
$identifier = ShortUrlIdentifier::fromApiRequest($request);
return $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey);
}

View File

@ -6,15 +6,16 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
use Pagerfanta\Pagerfanta;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagVisitsAction extends AbstractListVisitsAction
{
protected const string ROUTE_PATH = '/tags/{tag}/visits';
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
{
$params = WithDomainVisitsParams::fromRawData($request->getQueryParams());
$tag = $request->getAttribute('tag', '');
return $this->visitsHelper->visitsForTag($tag, $params, $apiKey);
}

View File

@ -31,5 +31,7 @@ class NonOrphanVisitsTest extends ApiTestCase
yield 'bots excluded' => [['excludeBots' => 'true'], 6, 6];
yield 'bots excluded and pagination' => [['excludeBots' => 'true', 'page' => 1, 'itemsPerPage' => 4], 6, 4];
yield 'date filter' => [['startDate' => Chronos::now()->addDays(1)->toAtomString()], 0, 0];
yield 'domain filter' => [['domain' => 'example.com'], 0, 0];
yield 'default domain filter' => [['domain' => 'DEFAULT'], 7, 7];
}
}

View File

@ -19,7 +19,7 @@ class OrphanVisitsTest extends ApiTestCase
'userAgent' => 'cf-facebook',
'visitLocation' => null,
'potentialBot' => true,
'visitedUrl' => 'foo.com',
'visitedUrl' => 'https://example.com/short',
'type' => 'invalid_short_url',
'redirectUrl' => null,
];
@ -29,7 +29,7 @@ class OrphanVisitsTest extends ApiTestCase
'userAgent' => 'shlink-tests-agent',
'visitLocation' => null,
'potentialBot' => false,
'visitedUrl' => '',
'visitedUrl' => 'https://s.test/bar',
'type' => 'regular_404',
'redirectUrl' => null,
];
@ -39,7 +39,7 @@ class OrphanVisitsTest extends ApiTestCase
'userAgent' => 'shlink-tests-agent',
'visitLocation' => null,
'potentialBot' => false,
'visitedUrl' => '',
'visitedUrl' => 'https://s.test/foo',
'type' => 'base_url',
'redirectUrl' => null,
];
@ -80,6 +80,14 @@ class OrphanVisitsTest extends ApiTestCase
1,
[self::INVALID_SHORT_URL],
];
yield 'example domain only' => [['domain' => 'example.com'], 1, 1, [self::INVALID_SHORT_URL]];
yield 'default domain only' => [['domain' => 's.test'], 2, 2, [self::REGULAR_NOT_FOUND, self::BASE_URL]];
yield 'default domain only with DEFAULT keyword' => [
['domain' => 'DEFAULT'],
2,
2,
[self::REGULAR_NOT_FOUND, self::BASE_URL],
];
}
#[Test]

View File

@ -17,11 +17,11 @@ class TagVisitsTest extends ApiTestCase
public function expectedVisitsAreReturned(
string $apiKey,
string $tag,
bool $excludeBots,
array $query,
int $expectedVisitsAmount,
): void {
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [
RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [],
RequestOptions::QUERY => $query,
], $apiKey);
$payload = $this->getJsonResponsePayload($resp);
@ -33,16 +33,18 @@ class TagVisitsTest extends ApiTestCase
public static function provideTags(): iterable
{
yield 'foo with admin API key' => ['valid_api_key', 'foo', false, 5];
yield 'foo with admin API key and no bots' => ['valid_api_key', 'foo', true, 4];
yield 'bar with admin API key' => ['valid_api_key', 'bar', false, 2];
yield 'bar with admin API key and no bots' => ['valid_api_key', 'bar', true, 1];
yield 'baz with admin API key' => ['valid_api_key', 'baz', false, 0];
yield 'foo with author API key' => ['author_api_key', 'foo', false, 5];
yield 'foo with author API key and no bots' => ['author_api_key', 'foo', true, 4];
yield 'bar with author API key' => ['author_api_key', 'bar', false, 2];
yield 'bar with author API key and no bots' => ['author_api_key', 'bar', true, 1];
yield 'foo with domain API key' => ['domain_api_key', 'foo', false, 0];
yield 'foo with admin API key' => ['valid_api_key', 'foo', [], 5];
yield 'foo with admin API key and no bots' => ['valid_api_key', 'foo', ['excludeBots' => true], 4];
yield 'bar with admin API key' => ['valid_api_key', 'bar', [], 2];
yield 'bar with admin API key and no bots' => ['valid_api_key', 'bar', ['excludeBots' => true], 1];
yield 'baz with admin API key' => ['valid_api_key', 'baz', [], 0];
yield 'foo with author API key' => ['author_api_key', 'foo', [], 5];
yield 'foo with author API key and no bots' => ['author_api_key', 'foo', ['excludeBots' => true], 4];
yield 'bar with author API key' => ['author_api_key', 'bar', [], 2];
yield 'bar with author API key and no bots' => ['author_api_key', 'bar', ['excludeBots' => true], 1];
yield 'foo with domain API key' => ['domain_api_key', 'foo', [], 0];
yield 'foo with specific domain' => ['valid_api_key', 'foo', ['domain' => 'example.com'], 0];
yield 'foo with default domain' => ['valid_api_key', 'foo', ['domain' => 'DEFAULT'], 5];
}
#[Test, DataProvider('provideApiKeysAndTags')]

View File

@ -58,20 +58,32 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''),
));
// Orphan visits (s.test is the default domain in tests env)
$manager->persist($this->setVisitDate(
fn () => Visit::forBasePath(Visitor::fromParams('shlink-tests-agent', 'https://s.test', '1.2.3.4')),
fn () => Visit::forBasePath(Visitor::fromParams(
'shlink-tests-agent',
'https://s.test',
'1.2.3.4',
visitedUrl: 'https://s.test/foo',
)),
'2020-01-01',
));
$manager->persist($this->setVisitDate(
fn () => Visit::forRegularNotFound(
Visitor::fromParams('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4'),
),
fn () => Visit::forRegularNotFound(Visitor::fromParams(
'shlink-tests-agent',
'https://s.test/foo/bar',
'1.2.3.4',
visitedUrl: 'https://s.test/bar',
)),
'2020-02-01',
));
$manager->persist($this->setVisitDate(
fn () => Visit::forInvalidShortUrl(
Visitor::fromParams('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com'),
),
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(
'cf-facebook',
'https://s.test/foo',
'1.2.3.4',
visitedUrl: 'https://example.com/short',
)),
'2020-03-01',
));