mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 08:18:24 +03:00
Merge pull request #293 from acelaya/feature/visits-pagination
Feature/visits pagination
This commit is contained in:
commit
05e56cc845
17 changed files with 279 additions and 116 deletions
|
@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
|
||||
Adding the `-d` flag, it will be started as a background service. Then you can use the `./vendor/bin/zend-expressive-swoole stop` command in order to stop it.
|
||||
|
||||
* [#266](https://github.com/shlinkio/shlink/issues/266) Added pagination to `GET /short-urls/{shortCode}/visits` endpoint.
|
||||
|
||||
In order to make it backwards compatible, it keeps returning all visits by default, but it now allows to provide the `page` and `itemsPerPage` query parameters in order to configure the number of items to get.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#267](https://github.com/shlinkio/shlink/issues/267) API responses and the CLI interface is no longer translated and uses english always. Only not found error templates are still translated.
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
"zendframework/zend-expressive-fastroute": "^3.0",
|
||||
"zendframework/zend-expressive-helpers": "^5.0",
|
||||
"zendframework/zend-expressive-platesrenderer": "^2.0",
|
||||
"zendframework/zend-expressive-swoole": "^2.0",
|
||||
"zendframework/zend-expressive-swoole": "^2.0.1",
|
||||
"zendframework/zend-i18n": "^2.7",
|
||||
"zendframework/zend-inputfilter": "^2.8",
|
||||
"zendframework/zend-paginator": "^2.6",
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// FIXME Dummy file just to prevent expressive-swoole fail while loading
|
||||
return function () {
|
||||
};
|
|
@ -1,6 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// FIXME Dummy file just to prevent expressive-swoole fail while loading
|
||||
return function () {
|
||||
};
|
|
@ -33,6 +33,24 @@
|
|||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
@ -59,6 +77,9 @@
|
|||
"items": {
|
||||
"$ref": "../definitions/Visit.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,7 +117,14 @@
|
|||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
}
|
||||
]
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
|||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
|
@ -13,6 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Zend\Stdlib\ArrayUtils;
|
||||
use function array_map;
|
||||
use function Functional\select_keys;
|
||||
|
||||
|
@ -72,7 +74,9 @@ class GetVisitsCommand extends Command
|
|||
$startDate = $this->getDateOption($input, 'startDate');
|
||||
$endDate = $this->getDateOption($input, 'endDate');
|
||||
|
||||
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
|
||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||
$visits = ArrayUtils::iteratorToArray($paginator->getCurrentItems());
|
||||
|
||||
$rows = array_map(function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
||||
|
|
|
@ -13,10 +13,12 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
|||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use function strpos;
|
||||
use Zend\Paginator\Adapter\ArrayAdapter;
|
||||
use Zend\Paginator\Paginator;
|
||||
|
||||
class GetVisitsCommandTest extends TestCase
|
||||
{
|
||||
|
@ -40,8 +42,9 @@ class GetVisitsCommandTest extends TestCase
|
|||
public function noDateFlagsTriesToListWithoutDateRange()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
|
||||
new Paginator(new ArrayAdapter([]))
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'shortcode:visits',
|
||||
|
@ -57,8 +60,11 @@ class GetVisitsCommandTest extends TestCase
|
|||
$shortCode = 'abc123';
|
||||
$startDate = '2016-01-01';
|
||||
$endDate = '2016-02-01';
|
||||
$this->visitsTracker->info($shortCode, new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
|
||||
->willReturn([])
|
||||
$this->visitsTracker->info(
|
||||
$shortCode,
|
||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)))
|
||||
)
|
||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute([
|
||||
|
@ -75,19 +81,21 @@ class GetVisitsCommandTest extends TestCase
|
|||
public function outputIsProperlyGenerated()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
|
||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([
|
||||
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
||||
new VisitLocation(['country_name' => 'Spain'])
|
||||
),
|
||||
])->shouldBeCalledOnce();
|
||||
]))
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute([
|
||||
'command' => 'shortcode:visits',
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertGreaterThan(0, strpos($output, 'foo'));
|
||||
$this->assertGreaterThan(0, strpos($output, 'Spain'));
|
||||
$this->assertGreaterThan(0, strpos($output, 'bar'));
|
||||
$this->assertContains('foo', $output);
|
||||
$this->assertContains('Spain', $output);
|
||||
$this->assertContains('bar', $output);
|
||||
}
|
||||
}
|
||||
|
|
61
module/Core/src/Model/VisitsParams.php
Normal file
61
module/Core/src/Model/VisitsParams.php
Normal file
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
final class VisitsParams
|
||||
{
|
||||
/** @var null|DateRange */
|
||||
private $dateRange;
|
||||
/** @var int */
|
||||
private $page = 1;
|
||||
/** @var null|int */
|
||||
private $itemsPerPage;
|
||||
|
||||
public function __construct(?DateRange $dateRange = null, int $page = 1, ?int $itemsPerPage = null)
|
||||
{
|
||||
$this->dateRange = $dateRange ?? new DateRange();
|
||||
$this->page = $page;
|
||||
$this->itemsPerPage = $itemsPerPage;
|
||||
}
|
||||
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$startDate = self::getDateQueryParam($query, 'startDate');
|
||||
$endDate = self::getDateQueryParam($query, 'endDate');
|
||||
|
||||
return new self(
|
||||
new DateRange($startDate, $endDate),
|
||||
(int) ($query['page'] ?? 1),
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null
|
||||
);
|
||||
}
|
||||
|
||||
private static function getDateQueryParam(array $query, string $key): ?Chronos
|
||||
{
|
||||
return ! isset($query[$key]) || empty($query[$key]) ? null : Chronos::parse($query[$key]);
|
||||
}
|
||||
|
||||
public function getDateRange(): DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
}
|
||||
|
||||
public function getPage(): int
|
||||
{
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): ?int
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function hasItemsPerPage(): bool
|
||||
{
|
||||
return $this->itemsPerPage !== null;
|
||||
}
|
||||
}
|
40
module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php
Normal file
40
module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Zend\Paginator\Adapter\AdapterInterface;
|
||||
|
||||
class VisitsPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
/** @var VisitRepositoryInterface */
|
||||
private $visitRepository;
|
||||
/** @var string */
|
||||
private $shortCode;
|
||||
/** @var VisitsParams */
|
||||
private $params;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $visitRepository, string $shortCode, VisitsParams $params)
|
||||
{
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->shortCode = $shortCode;
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array
|
||||
{
|
||||
return $this->visitRepository->findVisitsByShortCode(
|
||||
$this->shortCode,
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset
|
||||
);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByShortCode($this->shortCode, $this->params->getDateRange());
|
||||
}
|
||||
}
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
|
@ -19,24 +19,42 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrl|int $shortUrlOrId
|
||||
* @param DateRange|null $dateRange
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findVisitsByShortUrl($shortUrlOrId, DateRange $dateRange = null): array
|
||||
{
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $shortUrlOrId instanceof ShortUrl
|
||||
? $shortUrlOrId
|
||||
: $this->getEntityManager()->find(ShortUrl::class, $shortUrlOrId);
|
||||
public function findVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
||||
$qb->select('v');
|
||||
|
||||
if ($shortUrl === null) {
|
||||
return [];
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('v');
|
||||
$qb->where($qb->expr()->eq('v.shortUrl', ':shortUrl'))
|
||||
->setParameter('shortUrl', $shortUrl)
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
||||
$qb->select('COUNT(DISTINCT v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByShortCodeQueryBuilder(string $shortCode, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 'su')
|
||||
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $shortCode)
|
||||
->orderBy('v.date', 'DESC') ;
|
||||
|
||||
// Apply date range filtering
|
||||
|
@ -49,6 +67,6 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
->setParameter('endDate', $dateRange->getEndDate());
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ namespace Shlinkio\Shlink\Core\Repository;
|
|||
|
||||
use Doctrine\Common\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
|
||||
interface VisitRepositoryInterface extends ObjectRepository
|
||||
|
@ -13,9 +12,14 @@ interface VisitRepositoryInterface extends ObjectRepository
|
|||
public function findUnlocatedVisits(): iterable;
|
||||
|
||||
/**
|
||||
* @param ShortUrl|int $shortUrl
|
||||
* @param DateRange|null $dateRange
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findVisitsByShortUrl($shortUrl, DateRange $dateRange = null): array;
|
||||
public function findVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int;
|
||||
}
|
||||
|
|
|
@ -4,12 +4,14 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Zend\Paginator\Paginator;
|
||||
use function sprintf;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
|
@ -43,23 +45,23 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @param string $shortCode
|
||||
* @param DateRange $dateRange
|
||||
* @return Visit[]
|
||||
* @return Visit[]|Paginator
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function info(string $shortCode, DateRange $dateRange = null): array
|
||||
public function info(string $shortCode, VisitsParams $params): Paginator
|
||||
{
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
if ($shortUrl === null) {
|
||||
/** @var ORM\EntityRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if ($repo->count(['shortCode' => $shortCode]) < 1) {
|
||||
throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode));
|
||||
}
|
||||
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
return $repo->findVisitsByShortUrl($shortUrl, $dateRange);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $shortCode, $params));
|
||||
$paginator->setItemCountPerPage($params->hasItemsPerPage() ? $params->getItemsPerPage() : -1)
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Zend\Paginator\Paginator;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
|
@ -18,10 +19,8 @@ interface VisitsTrackerInterface
|
|||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @param string $shortCode
|
||||
* @param DateRange $dateRange
|
||||
* @return Visit[]
|
||||
* @return Visit[]|Paginator
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function info(string $shortCode, DateRange $dateRange = null): array;
|
||||
public function info(string $shortCode, VisitsParams $params): Paginator;
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
/**
|
||||
* @test
|
||||
*/
|
||||
public function findVisitsByShortUrlReturnsProperData()
|
||||
public function findVisitsByShortCodeReturnsProperData()
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
|
@ -73,13 +73,38 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertCount(0, $this->repo->findVisitsByShortUrl('invalid'));
|
||||
$this->assertCount(6, $this->repo->findVisitsByShortUrl($shortUrl->getId()));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange(
|
||||
$this->assertCount(0, $this->repo->findVisitsByShortCode('invalid'));
|
||||
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortUrl->getShortCode()));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03')
|
||||
)));
|
||||
$this->assertCount(4, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange(
|
||||
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-03')
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function countVisitsByShortCodeReturnsProperData()
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
|
||||
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortUrl->getShortCode()));
|
||||
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03')
|
||||
)));
|
||||
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-03')
|
||||
)));
|
||||
}
|
||||
|
|
|
@ -5,14 +5,18 @@ namespace ShlinkioTest\Shlink\Core\Service;
|
|||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Zend\Stdlib\ArrayUtils;
|
||||
|
||||
class VisitsTrackerTest extends TestCase
|
||||
{
|
||||
|
@ -49,15 +53,14 @@ class VisitsTrackerTest extends TestCase
|
|||
public function trackedIpAddressGetsObfuscated()
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$test = $this;
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
|
||||
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
$this->em->persist(Argument::any())->will(function ($args) use ($test) {
|
||||
$this->em->persist(Argument::any())->will(function ($args) {
|
||||
/** @var Visit $visit */
|
||||
$visit = $args[0];
|
||||
$test->assertEquals('4.3.2.0', $visit->getRemoteAddr());
|
||||
Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr());
|
||||
})->shouldBeCalledOnce();
|
||||
$this->em->flush(Argument::type(Visit::class))->shouldBeCalledOnce();
|
||||
|
||||
|
@ -70,9 +73,8 @@ class VisitsTrackerTest extends TestCase
|
|||
public function infoReturnsVisistForCertainShortCode()
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar');
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl);
|
||||
$count = $repo->count(['shortCode' => $shortCode])->willReturn(1);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$list = [
|
||||
|
@ -80,9 +82,13 @@ class VisitsTrackerTest extends TestCase
|
|||
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
|
||||
];
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByShortUrl($shortUrl, null)->willReturn($list);
|
||||
$repo2->findVisitsByShortCode($shortCode, Argument::type(DateRange::class), 1, 0)->willReturn($list);
|
||||
$repo2->countVisitsByShortCode($shortCode, Argument::type(DateRange::class))->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$this->assertEquals($list, $this->visitsTracker->info($shortCode));
|
||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams());
|
||||
|
||||
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Exception;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Util\RestUtils;
|
||||
|
@ -18,6 +17,8 @@ use function sprintf;
|
|||
|
||||
class GetVisitsAction extends AbstractRestAction
|
||||
{
|
||||
use PaginatorUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/short-urls/{shortCode}/visits';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
|
@ -38,16 +39,12 @@ class GetVisitsAction extends AbstractRestAction
|
|||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$startDate = $this->getDateQueryParam($request, 'startDate');
|
||||
$endDate = $this->getDateQueryParam($request, 'endDate');
|
||||
|
||||
try {
|
||||
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
|
||||
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => [
|
||||
'data' => $visits,
|
||||
],
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$this->logger->warning('Provided nonexistent short code {e}', ['e' => $e]);
|
||||
|
@ -55,18 +52,6 @@ class GetVisitsAction extends AbstractRestAction
|
|||
'error' => RestUtils::getRestErrorCodeFromException($e),
|
||||
'message' => sprintf('Provided short code %s does not exist', $shortCode),
|
||||
], self::STATUS_NOT_FOUND);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Unexpected error while parsing short code {e}', ['e' => $e]);
|
||||
return new JsonResponse([
|
||||
'error' => RestUtils::UNKNOWN_ERROR,
|
||||
'message' => 'Unexpected error occurred',
|
||||
], self::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private function getDateQueryParam(Request $request, string $key): ?Chronos
|
||||
{
|
||||
$query = $request->getQueryParams();
|
||||
return ! isset($query[$key]) || empty($query[$key]) ? null : Chronos::parse($query[$key]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,15 +4,17 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Exception;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction;
|
||||
use Zend\Diactoros\ServerRequestFactory;
|
||||
use Zend\Paginator\Adapter\ArrayAdapter;
|
||||
use Zend\Paginator\Paginator;
|
||||
|
||||
class GetVisitsActionTest extends TestCase
|
||||
{
|
||||
|
@ -33,8 +35,9 @@ class GetVisitsActionTest extends TestCase
|
|||
public function providingCorrectShortCodeReturnsVisits()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willReturn([])
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willReturn(
|
||||
new Paginator(new ArrayAdapter([]))
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode));
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
@ -46,7 +49,7 @@ class GetVisitsActionTest extends TestCase
|
|||
public function providingInvalidShortCodeReturnsError()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
|
||||
$this->visitsTracker->info($shortCode, Argument::type(VisitsParams::class))->willThrow(
|
||||
InvalidArgumentException::class
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
|
@ -54,28 +57,16 @@ class GetVisitsActionTest extends TestCase
|
|||
$this->assertEquals(404, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function unexpectedExceptionWillReturnError()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::type(DateRange::class))->willThrow(
|
||||
Exception::class
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
$response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode));
|
||||
$this->assertEquals(500, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
*/
|
||||
public function datesAreReadFromQuery()
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, new DateRange(null, Chronos::parse('2016-01-01 00:00:00')))
|
||||
->willReturn([])
|
||||
$this->visitsTracker->info($shortCode, new VisitsParams(
|
||||
new DateRange(null, Chronos::parse('2016-01-01 00:00:00'))
|
||||
))
|
||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$response = $this->action->handle(
|
||||
|
|
Loading…
Reference in a new issue