mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 08:18:24 +03:00
Merge pull request #1333 from acelaya-forks/feature/all-visits-endpoint
Feature/all visits endpoint
This commit is contained in:
commit
fb43885d85
29 changed files with 645 additions and 118 deletions
|
@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
|
||||
Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned.
|
||||
|
||||
* [#1063](https://github.com/shlinkio/shlink/issues/1063) Added new endpoint that allows fetching all existing non-orphan visits, in case you need a global view of all visits received by your Shlink instance.
|
||||
|
||||
This can be achieved using the `GET /visits/non-orphan` endpoint.
|
||||
|
||||
### Changed
|
||||
* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% of the original size.
|
||||
* [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4.
|
||||
|
|
146
docs/swagger/paths/v2_visits_non-orphan.json
Normal file
146
docs/swagger/paths/v2_visits_non-orphan.json
Normal file
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"get": {
|
||||
"operationId": "getNonOrphanVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List non-orphan visits",
|
||||
"description": "Get the list of visits to any short URL.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) from which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) until which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "The page to display. Defaults to 1",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "itemsPerPage",
|
||||
"in": "query",
|
||||
"description": "The amount of items to return on every page. Defaults to all the items",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeBots",
|
||||
"in": "query",
|
||||
"description": "Tells if visits from potential bots should be excluded from the result set",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of visits.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/Visit.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"visits": {
|
||||
"data": [
|
||||
{
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null,
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
},
|
||||
"potentialBot": false
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null,
|
||||
"potentialBot": true
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -98,6 +98,9 @@
|
|||
"/rest/v{version}/visits/orphan": {
|
||||
"$ref": "paths/v2_visits_orphan.json"
|
||||
},
|
||||
"/rest/v{version}/visits/non-orphan": {
|
||||
"$ref": "paths/v2_visits_non-orphan.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/domains": {
|
||||
"$ref": "paths/v2_domains.json"
|
||||
|
|
|
@ -18,7 +18,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
|||
) {
|
||||
}
|
||||
|
||||
public function getSlice($offset, $length): array // phpcs:ignore
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repository->findList(
|
||||
$length,
|
||||
|
|
|
@ -60,9 +60,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
|
|||
}
|
||||
|
||||
$apiKey = $filtering?->apiKey();
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($subQb, $apiKey->spec(false, 'shortUrls'), 't');
|
||||
}
|
||||
$this->applySpecification($subQb, $apiKey?->spec(false, 'shortUrls'), 't');
|
||||
|
||||
$subQuery = $subQb->getQuery();
|
||||
$subQuerySql = $subQuery->getSQL();
|
||||
|
|
|
@ -14,9 +14,8 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
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\Spec\CountOfShortUrlVisits;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
|
@ -53,10 +52,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v');
|
||||
|
||||
$qb = $this->createQueryBuilder('v');
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
|
@ -107,11 +103,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
): QueryBuilder {
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec());
|
||||
$shortUrlId = $shortUrl?->getId() ?? '-1';
|
||||
$shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->getId() ?? '-1';
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
// Since they are not provided by the caller, it's reasonably safe
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
|
||||
|
@ -142,8 +137,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, VisitsCountFiltering $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 strictly 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.
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 's')
|
||||
|
@ -155,25 +149,15 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
}
|
||||
|
||||
$this->applyDatesInline($qb, $filtering->dateRange());
|
||||
$this->applySpecification($qb, $filtering->spec(), 'v');
|
||||
$this->applySpecification($qb, $filtering->apiKey()?->spec(true), 'v');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findOrphanVisits(VisitsListFiltering $filtering): array
|
||||
{
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.shortUrl'));
|
||||
|
||||
if ($filtering->excludeBots()) {
|
||||
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
|
||||
}
|
||||
|
||||
$this->applyDatesInline($qb, $filtering->dateRange());
|
||||
|
||||
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
|
||||
}
|
||||
|
||||
|
@ -182,18 +166,49 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
|||
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering));
|
||||
}
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findNonOrphanVisits(VisitsListFiltering $filtering): array
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey));
|
||||
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||
$qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
|
||||
|
||||
$this->applySpecification($qb, $filtering->apiKey()?->spec(true));
|
||||
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
|
||||
}
|
||||
|
||||
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering));
|
||||
}
|
||||
|
||||
private function createAllVisitsQueryBuilder(VisitsListFiltering $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
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v');
|
||||
|
||||
if ($filtering->excludeBots()) {
|
||||
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
|
||||
}
|
||||
|
||||
$this->applyDatesInline($qb, $filtering->dateRange());
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||
{
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
if ($dateRange?->startDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->startDate()->toDateTimeString() . '\''));
|
||||
$qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate()->toDateTimeString())));
|
||||
}
|
||||
if ($dateRange?->endDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->endDate()->toDateTimeString() . '\''));
|
||||
$qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate()->toDateTimeString())));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,8 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
// TODO Split into VisitsListsRepository and VisitsLocationRepository
|
||||
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
{
|
||||
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||
|
@ -52,5 +52,10 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
|
|||
|
||||
public function countOrphanVisits(VisitsCountFiltering $filtering): int;
|
||||
|
||||
public function countVisits(?ApiKey $apiKey = null): int;
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findNonOrphanVisits(VisitsListFiltering $filtering): array;
|
||||
|
||||
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
public function __construct(
|
||||
private VisitRepositoryInterface $repo,
|
||||
private VisitsParams $params,
|
||||
private ?ApiKey $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->repo->countNonOrphanVisits(new VisitsCountFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->apiKey,
|
||||
));
|
||||
}
|
||||
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repo->findNonOrphanVisits(new VisitsListFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->apiKey,
|
||||
$length,
|
||||
$offset,
|
||||
));
|
||||
}
|
||||
}
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
|
@ -23,7 +24,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
|||
));
|
||||
}
|
||||
|
||||
public function getSlice($offset, $length): iterable // phpcs:ignore
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repo->findOrphanVisits(new VisitsListFiltering(
|
||||
$this->params->getDateRange(),
|
|
@ -2,33 +2,34 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
public function __construct(
|
||||
private VisitRepositoryInterface $visitRepository,
|
||||
private ShortUrlIdentifier $identifier,
|
||||
private VisitsParams $params,
|
||||
private ?Specification $spec,
|
||||
private ?ApiKey $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSlice($offset, $length): array // phpcs:ignore
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->visitRepository->findVisitsByShortCode(
|
||||
$this->identifier,
|
||||
new VisitsListFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->spec,
|
||||
$this->apiKey,
|
||||
$length,
|
||||
$offset,
|
||||
),
|
||||
|
@ -42,7 +43,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
|||
new VisitsCountFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->spec,
|
||||
$this->apiKey,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
public function __construct(
|
||||
private VisitRepositoryInterface $visitRepository,
|
||||
|
@ -20,14 +21,14 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
|||
) {
|
||||
}
|
||||
|
||||
public function getSlice($offset, $length): array // phpcs:ignore
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->visitRepository->findVisitsByTag(
|
||||
$this->tag,
|
||||
new VisitsListFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->apiKey?->spec(true),
|
||||
$this->apiKey,
|
||||
$length,
|
||||
$offset,
|
||||
),
|
||||
|
@ -41,7 +42,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
|||
new VisitsCountFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->apiKey?->spec(true),
|
||||
$this->apiKey,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -4,18 +4,23 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsCountFiltering
|
||||
{
|
||||
public function __construct(
|
||||
private ?DateRange $dateRange = null,
|
||||
private bool $excludeBots = false,
|
||||
private ?Specification $spec = null,
|
||||
private ?ApiKey $apiKey = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function withApiKey(?ApiKey $apiKey): self
|
||||
{
|
||||
return new self(null, false, $apiKey);
|
||||
}
|
||||
|
||||
public function dateRange(): ?DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
|
@ -26,8 +31,8 @@ class VisitsCountFiltering
|
|||
return $this->excludeBots;
|
||||
}
|
||||
|
||||
public function spec(): ?Specification
|
||||
public function apiKey(): ?ApiKey
|
||||
{
|
||||
return $this->spec;
|
||||
return $this->apiKey;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,19 +4,19 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
final class VisitsListFiltering extends VisitsCountFiltering
|
||||
{
|
||||
public function __construct(
|
||||
?DateRange $dateRange = null,
|
||||
bool $excludeBots = false,
|
||||
?Specification $spec = null,
|
||||
?ApiKey $apiKey = null,
|
||||
private ?int $limit = null,
|
||||
private ?int $offset = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $excludeBots, $spec);
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey);
|
||||
}
|
||||
|
||||
public function limit(): ?int
|
||||
|
|
39
module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php
Normal file
39
module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?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 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));
|
||||
}
|
||||
}
|
|
@ -1,27 +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\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class CountOfShortUrlVisits extends BaseSpecification
|
||||
{
|
||||
public function __construct(private ?ApiKey $apiKey)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
return Spec::countOf(Spec::andX(
|
||||
Spec::isNotNull('shortUrl'),
|
||||
new WithApiKeySpecsEnsuringJoin($this->apiKey, 'shortUrl'),
|
||||
));
|
||||
}
|
||||
}
|
|
@ -14,14 +14,15 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
|||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
|
@ -37,7 +38,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return new VisitsStats(
|
||||
$visitsRepo->countVisits($apiKey),
|
||||
$visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)),
|
||||
$visitsRepo->countOrphanVisits(new VisitsCountFiltering()),
|
||||
);
|
||||
}
|
||||
|
@ -51,18 +52,19 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||
VisitsParams $params,
|
||||
?ApiKey $apiKey = null,
|
||||
): Paginator {
|
||||
$spec = $apiKey?->spec();
|
||||
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if (! $repo->shortCodeIsInUse($identifier, $spec)) {
|
||||
if (! $repo->shortCodeIsInUse($identifier, $apiKey?->spec())) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec), $params);
|
||||
return $this->createPaginator(
|
||||
new ShortUrlVisitsPaginatorAdapter($repo, $identifier, $params, $apiKey),
|
||||
$params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -80,7 +82,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
|
||||
return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,6 +96,14 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
|||
return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params);
|
||||
}
|
||||
|
||||
public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
|
||||
{
|
||||
$paginator = new Paginator($adapter);
|
||||
|
|
|
@ -37,4 +37,9 @@ interface VisitsStatsHelperInterface
|
|||
* @return Visit[]|Paginator
|
||||
*/
|
||||
public function orphanVisits(VisitsParams $params): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
*/
|
||||
public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,9 @@ use function Functional\map;
|
|||
use function is_string;
|
||||
use function range;
|
||||
use function sprintf;
|
||||
use function str_pad;
|
||||
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
class VisitRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
|
@ -189,19 +192,19 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
|
||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
||||
new VisitsListFiltering(null, false, $adminApiKey),
|
||||
));
|
||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
||||
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
||||
new VisitsListFiltering(null, false, $adminApiKey),
|
||||
));
|
||||
self::assertEmpty($this->repo->findVisitsByShortCode(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||
new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
|
||||
new VisitsListFiltering(null, false, $restrictedApiKey),
|
||||
));
|
||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
||||
new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
|
||||
new VisitsListFiltering(null, false, $restrictedApiKey),
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -294,10 +297,20 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(4 + 5 + 7, $this->repo->countVisits());
|
||||
self::assertEquals(4, $this->repo->countVisits($apiKey1));
|
||||
self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2));
|
||||
self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
|
||||
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering()));
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1)));
|
||||
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2)));
|
||||
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey)));
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
|
||||
Chronos::parse('2016-01-05')->startOfDay(),
|
||||
))));
|
||||
self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
), false, $apiKey1)));
|
||||
self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
|
||||
Chronos::parse('2016-01-07')->startOfDay(),
|
||||
), false, $apiKey2)));
|
||||
self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2)));
|
||||
self::assertEquals(4, $this->repo->countOrphanVisits(new VisitsCountFiltering()));
|
||||
self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true)));
|
||||
}
|
||||
|
@ -388,6 +401,49 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findNonOrphanVisitsReturnsExpectedResult(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '1']));
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->createVisitsForShortUrl($shortUrl, 7);
|
||||
|
||||
$shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '2']));
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->createVisitsForShortUrl($shortUrl2, 4);
|
||||
|
||||
$shortUrl3 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '3']));
|
||||
$this->getEntityManager()->persist($shortUrl3);
|
||||
$this->createVisitsForShortUrl($shortUrl3, 10);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering()));
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::emptyInstance())));
|
||||
self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartDate(
|
||||
Chronos::parse('2016-01-05')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withEndDate(
|
||||
Chronos::parse('2016-01-04')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-04')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-08')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-08')->endOfDay(),
|
||||
), false, null, 10, 10)));
|
||||
self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, true)));
|
||||
self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10)));
|
||||
self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10, 20)));
|
||||
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string, ShortUrl}
|
||||
*/
|
||||
|
@ -429,7 +485,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
$shortUrl,
|
||||
$botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(),
|
||||
),
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
Chronos::parse(sprintf('2016-01-%s', str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT)))->startOfDay(),
|
||||
);
|
||||
$botsAmount--;
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
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\Rest\Entity\ApiKey;
|
||||
|
||||
class NonOrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private NonOrphanVisitsPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
private VisitsParams $params;
|
||||
private ApiKey $apiKey;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->params = VisitsParams::fromRawData([]);
|
||||
$this->apiKey = ApiKey::create();
|
||||
|
||||
$this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params, $this->apiKey);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countDelegatesToRepository(): void
|
||||
{
|
||||
$expectedCount = 5;
|
||||
$repoCount = $this->repo->countNonOrphanVisits(
|
||||
new VisitsCountFiltering($this->params->getDateRange(), $this->params->excludeBots(), $this->apiKey),
|
||||
)->willReturn($expectedCount);
|
||||
|
||||
$result = $this->adapter->getNbResults();
|
||||
|
||||
self::assertEquals($expectedCount, $result);
|
||||
$repoCount->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideLimitAndOffset
|
||||
*/
|
||||
public function getSliceDelegatesToRepository(int $limit, int $offset): void
|
||||
{
|
||||
$visitor = Visitor::emptyInstance();
|
||||
$list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)];
|
||||
$repoFind = $this->repo->findNonOrphanVisits(new VisitsListFiltering(
|
||||
$this->params->getDateRange(),
|
||||
$this->params->excludeBots(),
|
||||
$this->apiKey,
|
||||
$limit,
|
||||
$offset,
|
||||
))->willReturn($list);
|
||||
|
||||
$result = $this->adapter->getSlice($offset, $limit);
|
||||
|
||||
self::assertEquals($list, $result);
|
||||
$repoFind->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideLimitAndOffset(): iterable
|
||||
{
|
||||
yield [1, 5];
|
||||
yield [10, 4];
|
||||
yield [30, 18];
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
@ -10,8 +10,8 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
@ -10,13 +10,13 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class VisitsPaginatorAdapterTest extends TestCase
|
||||
class ShortUrlVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
|
@ -54,7 +54,7 @@ class VisitsPaginatorAdapterTest extends TestCase
|
|||
$adapter = $this->createAdapter($apiKey);
|
||||
$countVisits = $this->repo->countVisitsByShortCode(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain(''),
|
||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
|
||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
|
||||
)->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
|
@ -64,13 +64,13 @@ class VisitsPaginatorAdapterTest extends TestCase
|
|||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter
|
||||
private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter
|
||||
{
|
||||
return new VisitsPaginatorAdapter(
|
||||
return new ShortUrlVisitsPaginatorAdapter(
|
||||
$this->repo->reveal(),
|
||||
new ShortUrlIdentifier(''),
|
||||
VisitsParams::fromRawData([]),
|
||||
$apiKey?->spec(),
|
||||
$apiKey,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
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\Rest\Entity\ApiKey;
|
||||
|
@ -53,7 +53,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
|||
$adapter = $this->createAdapter($apiKey);
|
||||
$countVisits = $this->repo->countVisitsByTag(
|
||||
'foo',
|
||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
|
||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
|
||||
)->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
|
@ -63,9 +63,9 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
|||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter
|
||||
private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter
|
||||
{
|
||||
return new VisitsForTagPaginatorAdapter(
|
||||
return new TagVisitsPaginatorAdapter(
|
||||
$this->repo->reveal(),
|
||||
'foo',
|
||||
VisitsParams::fromRawData([]),
|
|
@ -53,7 +53,7 @@ class VisitsStatsHelperTest extends TestCase
|
|||
public function returnsExpectedVisitsStats(int $expectedCount): void
|
||||
{
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$count = $repo->countVisits(null)->willReturn($expectedCount * 3);
|
||||
$count = $repo->countNonOrphanVisits(new VisitsCountFiltering())->willReturn($expectedCount * 3);
|
||||
$countOrphan = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(
|
||||
$expectedCount,
|
||||
);
|
||||
|
@ -174,4 +174,23 @@ class VisitsStatsHelperTest extends TestCase
|
|||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function nonOrphanVisitsAreReturnedAsExpected(): void
|
||||
{
|
||||
$list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$countVisits = $repo->countNonOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(
|
||||
count($list),
|
||||
);
|
||||
$listVisits = $repo->findNonOrphanVisits(Argument::type(VisitsListFiltering::class))->willReturn($list);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$paginator = $this->helper->nonOrphanVisits(new VisitsParams());
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
$listVisits->shouldHaveBeenCalledOnce();
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,6 +34,7 @@ return [
|
|||
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
||||
|
@ -74,6 +75,7 @@ return [
|
|||
Visit\VisitsStatsHelper::class,
|
||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||
],
|
||||
Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
|
||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||
Action\Tag\TagsStatsAction::class => [TagService::class],
|
||||
|
|
|
@ -34,6 +34,7 @@ return [
|
|||
Action\Visit\TagVisitsAction::getRouteDef(),
|
||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||
Action\Visit\OrphanVisitsAction::getRouteDef(),
|
||||
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
|
||||
|
||||
// Tags
|
||||
Action\Tag\ListTagsAction::getRouteDef(),
|
||||
|
|
37
module/Rest/src/Action/Visit/NonOrphanVisitsAction.php
Normal file
37
module/Rest/src/Action/Visit/NonOrphanVisitsAction.php
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
||||
class NonOrphanVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/visits/non-orphan';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
public function __construct(private VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$visits = $this->visitsHelper->nonOrphanVisits($params, $apiKey);
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
]);
|
||||
}
|
||||
}
|
36
module/Rest/test-api/Action/NonOrphanVisitsTest.php
Normal file
36
module/Rest/test-api/Action/NonOrphanVisitsTest.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
class NonOrphanVisitsTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideQueries
|
||||
*/
|
||||
public function properVisitsAreReturnedBasedInQuery(array $query, int $totalItems, int $returnedItems): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/visits/non-orphan', [RequestOptions::QUERY => $query]);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals($totalItems, $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS);
|
||||
self::assertCount($returnedItems, $payload['visits']['data'] ?? []);
|
||||
}
|
||||
|
||||
public function provideQueries(): iterable
|
||||
{
|
||||
yield 'all data' => [[], 7, 7];
|
||||
yield 'middle page' => [['page' => 2, 'itemsPerPage' => 3], 7, 3];
|
||||
yield 'last page' => [['page' => 3, 'itemsPerPage' => 3], 7, 1];
|
||||
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()->addDay()->toAtomString()], 0, 0];
|
||||
}
|
||||
}
|
49
module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php
Normal file
49
module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\NonOrphanVisitsAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class NonOrphanVisitsActionTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private NonOrphanVisitsAction $action;
|
||||
private ObjectProphecy $visitsHelper;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
|
||||
$this->action = new NonOrphanVisitsAction($this->visitsHelper->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function requestIsHandled(): void
|
||||
{
|
||||
$apiKey = ApiKey::create();
|
||||
$getVisits = $this->visitsHelper->nonOrphanVisits(Argument::type(VisitsParams::class), $apiKey)->willReturn(
|
||||
new Paginator(new ArrayAdapter([])),
|
||||
);
|
||||
|
||||
/** @var JsonResponse $response */
|
||||
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey));
|
||||
$payload = $response->getPayload();
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertArrayHasKey('visits', $payload);
|
||||
$getVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
|
@ -30,7 +30,7 @@ class TagVisitsActionTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function providingCorrectShortCodeReturnsVisits(): void
|
||||
public function providingCorrectTagReturnsVisits(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$apiKey = ApiKey::create();
|
||||
|
@ -39,7 +39,7 @@ class TagVisitsActionTest extends TestCase
|
|||
);
|
||||
|
||||
$response = $this->action->handle(
|
||||
(new ServerRequest())->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey),
|
||||
ServerRequestFactory::fromGlobals()->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey),
|
||||
);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
|
|
Loading…
Reference in a new issue