diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a33bd5b..46e47408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/swagger/paths/v2_visits_non-orphan.json b/docs/swagger/paths/v2_visits_non-orphan.json new file mode 100644 index 00000000..da0bdd14 --- /dev/null +++ b/docs/swagger/paths/v2_visits_non-orphan.json @@ -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" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index f04510d0..3730b527 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.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" diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 0be5403b..cf54e1b5 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -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, diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 0182908c..1aa35603 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -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(); diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 3a8440a7..67b4256f 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -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()))); } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 28f1e9a8..3d480c01 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -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; } diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php new file mode 100644 index 00000000..ba5b6663 --- /dev/null +++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php @@ -0,0 +1,42 @@ +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, + )); + } +} diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php similarity index 82% rename from module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 18c2c435..8a47c9d7 100644 --- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -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(), diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php similarity index 72% rename from module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 9ff13e3c..2e47fbf8 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -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, ), ); } diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php similarity index 76% rename from module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 20af1598..162b6cba 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -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, ), ); } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index bf459768..140ec9b9 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -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; } } diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index fb715182..b17964a6 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -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 diff --git a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php new file mode 100644 index 00000000..52be52a8 --- /dev/null +++ b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php @@ -0,0 +1,39 @@ +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)); + } +} diff --git a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php deleted file mode 100644 index 49d8db93..00000000 --- a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php +++ /dev/null @@ -1,27 +0,0 @@ -apiKey, 'shortUrl'), - )); - } -} diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 8138d170..914a9c5b 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -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); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 5e15be4f..3616b531 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -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; } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 475cf374..950bfc8a 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -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--; diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php new file mode 100644 index 00000000..4c4c00e5 --- /dev/null +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -0,0 +1,79 @@ +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]; + } +} diff --git a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php similarity index 93% rename from module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 1cc21eef..0ea91f29 100644 --- a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -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; diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php similarity index 84% rename from module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index 413ae1cd..04e17bc6 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -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, ); } } diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php similarity index 86% rename from module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index c92e21c6..442e7128 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -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([]), diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index ab76bbf1..731697e6 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -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(); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e7d99a85..5f0d5c05 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -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], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 49d9f107..16f83149 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -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(), diff --git a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php new file mode 100644 index 00000000..7d77a5b1 --- /dev/null +++ b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php @@ -0,0 +1,37 @@ +getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->nonOrphanVisits($params, $apiKey); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); + } +} diff --git a/module/Rest/test-api/Action/NonOrphanVisitsTest.php b/module/Rest/test-api/Action/NonOrphanVisitsTest.php new file mode 100644 index 00000000..c53e29cc --- /dev/null +++ b/module/Rest/test-api/Action/NonOrphanVisitsTest.php @@ -0,0 +1,36 @@ +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]; + } +} diff --git a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php new file mode 100644 index 00000000..5b3487f0 --- /dev/null +++ b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php @@ -0,0 +1,49 @@ +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(); + } +} diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index be3ce914..33907d09 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -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());