mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-29 04:52:54 +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.
|
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
|
### Changed
|
||||||
* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% of the original size.
|
* [#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.
|
* [#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": {
|
"/rest/v{version}/visits/orphan": {
|
||||||
"$ref": "paths/v2_visits_orphan.json"
|
"$ref": "paths/v2_visits_orphan.json"
|
||||||
},
|
},
|
||||||
|
"/rest/v{version}/visits/non-orphan": {
|
||||||
|
"$ref": "paths/v2_visits_non-orphan.json"
|
||||||
|
},
|
||||||
|
|
||||||
"/rest/v{version}/domains": {
|
"/rest/v{version}/domains": {
|
||||||
"$ref": "paths/v2_domains.json"
|
"$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(
|
return $this->repository->findList(
|
||||||
$length,
|
$length,
|
||||||
|
|
|
@ -60,9 +60,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
|
||||||
}
|
}
|
||||||
|
|
||||||
$apiKey = $filtering?->apiKey();
|
$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();
|
$subQuery = $subQb->getQuery();
|
||||||
$subQuerySql = $subQuery->getSQL();
|
$subQuerySql = $subQuery->getSQL();
|
||||||
|
|
|
@ -14,9 +14,8 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
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\CountOfOrphanVisits;
|
||||||
use Shlinkio\Shlink\Core\Visit\Spec\CountOfShortUrlVisits;
|
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|
||||||
|
|
||||||
use const PHP_INT_MAX;
|
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
|
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||||
{
|
{
|
||||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
$qb = $this->createQueryBuilder('v');
|
||||||
$qb->select('v')
|
|
||||||
->from(Visit::class, 'v');
|
|
||||||
|
|
||||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,11 +103,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||||
): QueryBuilder {
|
): QueryBuilder {
|
||||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||||
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
||||||
$shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec());
|
$shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->getId() ?? '-1';
|
||||||
$shortUrlId = $shortUrl?->getId() ?? '-1';
|
|
||||||
|
|
||||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
// 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 = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->from(Visit::class, 'v')
|
$qb->from(Visit::class, 'v')
|
||||||
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
|
->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
|
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
|
// 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 = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->from(Visit::class, 'v')
|
$qb->from(Visit::class, 'v')
|
||||||
->join('v.shortUrl', 's')
|
->join('v.shortUrl', 's')
|
||||||
|
@ -155,25 +149,15 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyDatesInline($qb, $filtering->dateRange());
|
$this->applyDatesInline($qb, $filtering->dateRange());
|
||||||
$this->applySpecification($qb, $filtering->spec(), 'v');
|
$this->applySpecification($qb, $filtering->apiKey()?->spec(true), 'v');
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findOrphanVisits(VisitsListFiltering $filtering): array
|
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
|
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
|
||||||
$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());
|
|
||||||
|
|
||||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
|
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));
|
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
|
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||||
{
|
{
|
||||||
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
|
|
||||||
if ($dateRange?->startDate() !== null) {
|
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) {
|
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\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
|
||||||
|
|
||||||
|
// TODO Split into VisitsListsRepository and VisitsLocationRepository
|
||||||
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||||
{
|
{
|
||||||
public const DEFAULT_BLOCK_SIZE = 10000;
|
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||||
|
@ -52,5 +52,10 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
|
||||||
|
|
||||||
public function countOrphanVisits(VisitsCountFiltering $filtering): int;
|
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);
|
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\Model\VisitsParams;
|
||||||
|
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
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(
|
return $this->repo->findOrphanVisits(new VisitsListFiltering(
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
|
@ -2,33 +2,34 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
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\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
|
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $visitRepository,
|
private VisitRepositoryInterface $visitRepository,
|
||||||
private ShortUrlIdentifier $identifier,
|
private ShortUrlIdentifier $identifier,
|
||||||
private VisitsParams $params,
|
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(
|
return $this->visitRepository->findVisitsByShortCode(
|
||||||
$this->identifier,
|
$this->identifier,
|
||||||
new VisitsListFiltering(
|
new VisitsListFiltering(
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
||||||
$this->params->excludeBots(),
|
$this->params->excludeBots(),
|
||||||
$this->spec,
|
$this->apiKey,
|
||||||
$length,
|
$length,
|
||||||
$offset,
|
$offset,
|
||||||
),
|
),
|
||||||
|
@ -42,7 +43,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
new VisitsCountFiltering(
|
new VisitsCountFiltering(
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
||||||
$this->params->excludeBots(),
|
$this->params->excludeBots(),
|
||||||
$this->spec,
|
$this->apiKey,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -2,15 +2,16 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
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\Model\VisitsParams;
|
||||||
|
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private VisitRepositoryInterface $visitRepository,
|
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(
|
return $this->visitRepository->findVisitsByTag(
|
||||||
$this->tag,
|
$this->tag,
|
||||||
new VisitsListFiltering(
|
new VisitsListFiltering(
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
||||||
$this->params->excludeBots(),
|
$this->params->excludeBots(),
|
||||||
$this->apiKey?->spec(true),
|
$this->apiKey,
|
||||||
$length,
|
$length,
|
||||||
$offset,
|
$offset,
|
||||||
),
|
),
|
||||||
|
@ -41,7 +42,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||||
new VisitsCountFiltering(
|
new VisitsCountFiltering(
|
||||||
$this->params->getDateRange(),
|
$this->params->getDateRange(),
|
||||||
$this->params->excludeBots(),
|
$this->params->excludeBots(),
|
||||||
$this->apiKey?->spec(true),
|
$this->apiKey,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -4,18 +4,23 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||||
|
|
||||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class VisitsCountFiltering
|
class VisitsCountFiltering
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ?DateRange $dateRange = null,
|
private ?DateRange $dateRange = null,
|
||||||
private bool $excludeBots = false,
|
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
|
public function dateRange(): ?DateRange
|
||||||
{
|
{
|
||||||
return $this->dateRange;
|
return $this->dateRange;
|
||||||
|
@ -26,8 +31,8 @@ class VisitsCountFiltering
|
||||||
return $this->excludeBots;
|
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;
|
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||||
|
|
||||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
final class VisitsListFiltering extends VisitsCountFiltering
|
final class VisitsListFiltering extends VisitsCountFiltering
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
?DateRange $dateRange = null,
|
?DateRange $dateRange = null,
|
||||||
bool $excludeBots = false,
|
bool $excludeBots = false,
|
||||||
?Specification $spec = null,
|
?ApiKey $apiKey = null,
|
||||||
private ?int $limit = null,
|
private ?int $limit = null,
|
||||||
private ?int $offset = null,
|
private ?int $offset = null,
|
||||||
) {
|
) {
|
||||||
parent::__construct($dateRange, $excludeBots, $spec);
|
parent::__construct($dateRange, $excludeBots, $apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function limit(): ?int
|
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\Exception\TagNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
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\ShortUrlRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
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\Core\Visit\Persistence\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
@ -37,7 +38,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||||
$visitsRepo = $this->em->getRepository(Visit::class);
|
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||||
|
|
||||||
return new VisitsStats(
|
return new VisitsStats(
|
||||||
$visitsRepo->countVisits($apiKey),
|
$visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)),
|
||||||
$visitsRepo->countOrphanVisits(new VisitsCountFiltering()),
|
$visitsRepo->countOrphanVisits(new VisitsCountFiltering()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -51,18 +52,19 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||||
VisitsParams $params,
|
VisitsParams $params,
|
||||||
?ApiKey $apiKey = null,
|
?ApiKey $apiKey = null,
|
||||||
): Paginator {
|
): Paginator {
|
||||||
$spec = $apiKey?->spec();
|
|
||||||
|
|
||||||
/** @var ShortUrlRepositoryInterface $repo */
|
/** @var ShortUrlRepositoryInterface $repo */
|
||||||
$repo = $this->em->getRepository(ShortUrl::class);
|
$repo = $this->em->getRepository(ShortUrl::class);
|
||||||
if (! $repo->shortCodeIsInUse($identifier, $spec)) {
|
if (! $repo->shortCodeIsInUse($identifier, $apiKey?->spec())) {
|
||||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var VisitRepositoryInterface $repo */
|
/** @var VisitRepositoryInterface $repo */
|
||||||
$repo = $this->em->getRepository(Visit::class);
|
$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 */
|
/** @var VisitRepositoryInterface $repo */
|
||||||
$repo = $this->em->getRepository(Visit::class);
|
$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);
|
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
|
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
|
||||||
{
|
{
|
||||||
$paginator = new Paginator($adapter);
|
$paginator = new Paginator($adapter);
|
||||||
|
|
|
@ -37,4 +37,9 @@ interface VisitsStatsHelperInterface
|
||||||
* @return Visit[]|Paginator
|
* @return Visit[]|Paginator
|
||||||
*/
|
*/
|
||||||
public function orphanVisits(VisitsParams $params): 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 is_string;
|
||||||
use function range;
|
use function range;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
use function str_pad;
|
||||||
|
|
||||||
|
use const STR_PAD_LEFT;
|
||||||
|
|
||||||
class VisitRepositoryTest extends DatabaseTestCase
|
class VisitRepositoryTest extends DatabaseTestCase
|
||||||
{
|
{
|
||||||
|
@ -189,19 +192,19 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||||
|
|
||||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||||
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
new VisitsListFiltering(null, false, $adminApiKey),
|
||||||
));
|
));
|
||||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
||||||
new VisitsListFiltering(null, false, $adminApiKey->spec()),
|
new VisitsListFiltering(null, false, $adminApiKey),
|
||||||
));
|
));
|
||||||
self::assertEmpty($this->repo->findVisitsByShortCode(
|
self::assertEmpty($this->repo->findVisitsByShortCode(
|
||||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
|
||||||
new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
|
new VisitsListFiltering(null, false, $restrictedApiKey),
|
||||||
));
|
));
|
||||||
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
self::assertNotEmpty($this->repo->findVisitsByShortCode(
|
||||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
|
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();
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
self::assertEquals(4 + 5 + 7, $this->repo->countVisits());
|
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering()));
|
||||||
self::assertEquals(4, $this->repo->countVisits($apiKey1));
|
self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1)));
|
||||||
self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2));
|
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2)));
|
||||||
self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
|
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(4, $this->repo->countOrphanVisits(new VisitsCountFiltering()));
|
||||||
self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true)));
|
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}
|
* @return array{string, string, ShortUrl}
|
||||||
*/
|
*/
|
||||||
|
@ -429,7 +485,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||||
$shortUrl,
|
$shortUrl,
|
||||||
$botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(),
|
$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--;
|
$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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
@ -10,8 +10,8 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
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\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
|
@ -10,13 +10,13 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
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\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class VisitsPaginatorAdapterTest extends TestCase
|
class ShortUrlVisitsPaginatorAdapterTest extends TestCase
|
||||||
{
|
{
|
||||||
use ProphecyTrait;
|
use ProphecyTrait;
|
||||||
|
|
||||||
|
@ -54,7 +54,7 @@ class VisitsPaginatorAdapterTest extends TestCase
|
||||||
$adapter = $this->createAdapter($apiKey);
|
$adapter = $this->createAdapter($apiKey);
|
||||||
$countVisits = $this->repo->countVisitsByShortCode(
|
$countVisits = $this->repo->countVisitsByShortCode(
|
||||||
ShortUrlIdentifier::fromShortCodeAndDomain(''),
|
ShortUrlIdentifier::fromShortCodeAndDomain(''),
|
||||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
|
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
|
||||||
)->willReturn(3);
|
)->willReturn(3);
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
@ -64,13 +64,13 @@ class VisitsPaginatorAdapterTest extends TestCase
|
||||||
$countVisits->shouldHaveBeenCalledOnce();
|
$countVisits->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter
|
private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter
|
||||||
{
|
{
|
||||||
return new VisitsPaginatorAdapter(
|
return new ShortUrlVisitsPaginatorAdapter(
|
||||||
$this->repo->reveal(),
|
$this->repo->reveal(),
|
||||||
new ShortUrlIdentifier(''),
|
new ShortUrlIdentifier(''),
|
||||||
VisitsParams::fromRawData([]),
|
VisitsParams::fromRawData([]),
|
||||||
$apiKey?->spec(),
|
$apiKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,15 +2,15 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\PhpUnit\ProphecyTrait;
|
use Prophecy\PhpUnit\ProphecyTrait;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
|
||||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
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\VisitsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
@ -53,7 +53,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||||
$adapter = $this->createAdapter($apiKey);
|
$adapter = $this->createAdapter($apiKey);
|
||||||
$countVisits = $this->repo->countVisitsByTag(
|
$countVisits = $this->repo->countVisitsByTag(
|
||||||
'foo',
|
'foo',
|
||||||
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
|
new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
|
||||||
)->willReturn(3);
|
)->willReturn(3);
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
@ -63,9 +63,9 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||||
$countVisits->shouldHaveBeenCalledOnce();
|
$countVisits->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter
|
private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter
|
||||||
{
|
{
|
||||||
return new VisitsForTagPaginatorAdapter(
|
return new TagVisitsPaginatorAdapter(
|
||||||
$this->repo->reveal(),
|
$this->repo->reveal(),
|
||||||
'foo',
|
'foo',
|
||||||
VisitsParams::fromRawData([]),
|
VisitsParams::fromRawData([]),
|
|
@ -53,7 +53,7 @@ class VisitsStatsHelperTest extends TestCase
|
||||||
public function returnsExpectedVisitsStats(int $expectedCount): void
|
public function returnsExpectedVisitsStats(int $expectedCount): void
|
||||||
{
|
{
|
||||||
$repo = $this->prophesize(VisitRepository::class);
|
$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(
|
$countOrphan = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(
|
||||||
$expectedCount,
|
$expectedCount,
|
||||||
);
|
);
|
||||||
|
@ -174,4 +174,23 @@ class VisitsStatsHelperTest extends TestCase
|
||||||
$countVisits->shouldHaveBeenCalledOnce();
|
$countVisits->shouldHaveBeenCalledOnce();
|
||||||
$getRepo->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\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||||
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
|
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||||
|
Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class,
|
||||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||||
Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class,
|
Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class,
|
||||||
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
||||||
|
@ -74,6 +75,7 @@ return [
|
||||||
Visit\VisitsStatsHelper::class,
|
Visit\VisitsStatsHelper::class,
|
||||||
Visit\Transformer\OrphanVisitDataTransformer::class,
|
Visit\Transformer\OrphanVisitDataTransformer::class,
|
||||||
],
|
],
|
||||||
|
Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||||
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
|
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
|
||||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||||
Action\Tag\TagsStatsAction::class => [TagService::class],
|
Action\Tag\TagsStatsAction::class => [TagService::class],
|
||||||
|
|
|
@ -34,6 +34,7 @@ return [
|
||||||
Action\Visit\TagVisitsAction::getRouteDef(),
|
Action\Visit\TagVisitsAction::getRouteDef(),
|
||||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||||
Action\Visit\OrphanVisitsAction::getRouteDef(),
|
Action\Visit\OrphanVisitsAction::getRouteDef(),
|
||||||
|
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
Action\Tag\ListTagsAction::getRouteDef(),
|
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;
|
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||||
|
|
||||||
use Laminas\Diactoros\ServerRequest;
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
use Pagerfanta\Adapter\ArrayAdapter;
|
use Pagerfanta\Adapter\ArrayAdapter;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\Argument;
|
use Prophecy\Argument;
|
||||||
|
@ -30,7 +30,7 @@ class TagVisitsActionTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function providingCorrectShortCodeReturnsVisits(): void
|
public function providingCorrectTagReturnsVisits(): void
|
||||||
{
|
{
|
||||||
$tag = 'foo';
|
$tag = 'foo';
|
||||||
$apiKey = ApiKey::create();
|
$apiKey = ApiKey::create();
|
||||||
|
@ -39,7 +39,7 @@ class TagVisitsActionTest extends TestCase
|
||||||
);
|
);
|
||||||
|
|
||||||
$response = $this->action->handle(
|
$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());
|
self::assertEquals(200, $response->getStatusCode());
|
||||||
|
|
Loading…
Add table
Reference in a new issue