Separated methods to track visits and list visits

This commit is contained in:
Alejandro Celaya 2021-02-08 19:46:51 +01:00
parent 12b07bb0ac
commit 1b4e62b823
14 changed files with 220 additions and 210 deletions

View file

@ -74,7 +74,7 @@ return [
Service\ShortUrlService::class, Service\ShortUrlService::class,
ShortUrlDataTransformer::class, ShortUrlDataTransformer::class,
], ],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class], Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
Command\Visit\LocateVisitsCommand::class => [ Command\Visit\LocateVisitsCommand::class => [

View file

@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
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\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation; use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
@ -27,11 +27,11 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
{ {
public const NAME = 'short-url:visits'; public const NAME = 'short-url:visits';
private VisitsTrackerInterface $visitsTracker; private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsTrackerInterface $visitsTracker) public function __construct(VisitsStatsHelperInterface $visitsHelper)
{ {
$this->visitsTracker = $visitsTracker; $this->visitsHelper = $visitsHelper;
parent::__construct(); parent::__construct();
} }
@ -74,7 +74,10 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
$startDate = $this->getStartDateOption($input, $output); $startDate = $this->getStartDateOption($input, $output);
$endDate = $this->getEndDateOption($input, $output); $endDate = $this->getEndDateOption($input, $output);
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate))); $paginator = $this->visitsHelper->visitsForShortUrl(
$identifier,
new VisitsParams(new DateRange($startDate, $endDate)),
);
$rows = map($paginator->getCurrentPageResults(), function (Visit $visit) { $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) {
$rowData = $visit->jsonSerialize(); $rowData = $visit->jsonSerialize();

View file

@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
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\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@ -31,12 +31,12 @@ class GetVisitsCommandTest extends TestCase
use ProphecyTrait; use ProphecyTrait;
private CommandTester $commandTester; private CommandTester $commandTester;
private ObjectProphecy $visitsTracker; private ObjectProphecy $visitsHelper;
public function setUp(): void public function setUp(): void
{ {
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$command = new GetVisitsCommand($this->visitsTracker->reveal()); $command = new GetVisitsCommand($this->visitsHelper->reveal());
$app = new Application(); $app = new Application();
$app->add($command); $app->add($command);
$this->commandTester = new CommandTester($command); $this->commandTester = new CommandTester($command);
@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
public function noDateFlagsTriesToListWithoutDateRange(): void public function noDateFlagsTriesToListWithoutDateRange(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->visitsTracker->info( $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode), new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(null, null)), new VisitsParams(new DateRange(null, null)),
) )
@ -62,7 +62,7 @@ class GetVisitsCommandTest extends TestCase
$shortCode = 'abc123'; $shortCode = 'abc123';
$startDate = '2016-01-01'; $startDate = '2016-01-01';
$endDate = '2016-02-01'; $endDate = '2016-02-01';
$this->visitsTracker->info( $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode), new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))), new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
) )
@ -81,8 +81,10 @@ class GetVisitsCommandTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$startDate = 'foo'; $startDate = 'foo';
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange())) $info = $this->visitsHelper->visitsForShortUrl(
->willReturn(new Paginator(new ArrayAdapter([]))); new ShortUrlIdentifier($shortCode),
new VisitsParams(new DateRange()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([ $this->commandTester->execute([
'shortCode' => $shortCode, 'shortCode' => $shortCode,
@ -101,7 +103,7 @@ class GetVisitsCommandTest extends TestCase
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
new Paginator(new ArrayAdapter([ new Paginator(new ArrayAdapter([
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')), new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),

View file

@ -6,22 +6,10 @@ namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM; use Doctrine\ORM;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
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\VisitRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsTracker implements VisitsTrackerInterface class VisitsTracker implements VisitsTrackerInterface
{ {
@ -48,48 +36,4 @@ class VisitsTracker implements VisitsTrackerInterface
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress())); $this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
} }
/**
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
$spec = $apiKey !== null ? $apiKey->spec() : null;
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec));
$paginator->setMaxPerPage($params->getItemsPerPage())
->setCurrentPage($params->getPage());
return $paginator;
}
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var TagRepository $tagRepo */
$tagRepo = $this->em->getRepository(Tag::class);
if (! $tagRepo->tagExists($tag, $apiKey)) {
throw TagNotFoundException::fromTag($tag);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey));
$paginator->setMaxPerPage($params->getItemsPerPage())
->setCurrentPage($params->getPage());
return $paginator;
}
} }

View file

@ -4,29 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitsTrackerInterface interface VisitsTrackerInterface
{ {
public function track(ShortUrl $shortUrl, Visitor $visitor): void; public function track(ShortUrl $shortUrl, Visitor $visitor): void;
/**
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(ShortUrlIdentifier $identifier, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
} }

View file

@ -5,8 +5,20 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit; namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
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\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\VisitRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -30,4 +42,51 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
$visitsRepo = $this->em->getRepository(Visit::class); $visitsRepo = $this->em->getRepository(Visit::class);
return $visitsRepo->countVisits($apiKey); return $visitsRepo->countVisits($apiKey);
} }
/**
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function visitsForShortUrl(
ShortUrlIdentifier $identifier,
VisitsParams $params,
?ApiKey $apiKey = null
): Paginator {
$spec = $apiKey !== null ? $apiKey->spec() : null;
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec));
$paginator->setMaxPerPage($params->getItemsPerPage())
->setCurrentPage($params->getPage());
return $paginator;
}
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var TagRepository $tagRepo */
$tagRepo = $this->em->getRepository(Tag::class);
if (! $tagRepo->tagExists($tag, $apiKey)) {
throw TagNotFoundException::fromTag($tag);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey));
$paginator->setMaxPerPage($params->getItemsPerPage())
->setCurrentPage($params->getPage());
return $paginator;
}
} }

View file

@ -4,10 +4,32 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit; namespace Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Visit;
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\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface VisitsStatsHelperInterface interface VisitsStatsHelperInterface
{ {
public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats;
/**
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function visitsForShortUrl(
ShortUrlIdentifier $identifier,
VisitsParams $params,
?ApiKey $apiKey = null
): Paginator;
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
} }

View file

@ -5,35 +5,19 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service; namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Laminas\Stdlib\ArrayUtils;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map;
use function range;
class VisitsTrackerTest extends TestCase class VisitsTrackerTest extends TestCase
{ {
use ApiKeyHelpersTrait;
use ProphecyTrait; use ProphecyTrait;
private VisitsTracker $visitsTracker; private VisitsTracker $visitsTracker;
@ -60,85 +44,4 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); $this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
} }
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
{
$shortCode = '123ABC';
$spec = $apiKey === null ? null : $apiKey->spec();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
$list,
);
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$count->shouldHaveBeenCalledOnce();
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->expectException(ShortUrlNotFoundException::class);
$count->shouldBeCalledOnce();
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
{
$tag = 'foo';
$apiKey = new ApiKey();
$repo = $this->prophesize(TagRepository::class);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->expectException(TagNotFoundException::class);
$tagExists->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
}
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$spec = $apiKey === null ? null : $apiKey->spec();
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list);
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$tagExists->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
} }

View file

@ -5,19 +5,34 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Visit; namespace ShlinkioTest\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Laminas\Stdlib\ArrayUtils;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
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\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait;
use function Functional\map; use function Functional\map;
use function range; use function range;
class VisitsStatsHelperTest extends TestCase class VisitsStatsHelperTest extends TestCase
{ {
use ApiKeyHelpersTrait;
use ProphecyTrait; use ProphecyTrait;
private VisitsStatsHelper $helper; private VisitsStatsHelper $helper;
@ -50,4 +65,85 @@ class VisitsStatsHelperTest extends TestCase
{ {
return map(range(0, 50, 5), fn (int $value) => [$value]); return map(range(0, 50, 5), fn (int $value) => [$value]);
} }
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void
{
$shortCode = '123ABC';
$spec = $apiKey === null ? null : $apiKey->spec();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn(
$list,
);
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$count->shouldHaveBeenCalledOnce();
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
{
$shortCode = '123ABC';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
$this->expectException(ShortUrlNotFoundException::class);
$count->shouldBeCalledOnce();
$this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams());
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
{
$tag = 'foo';
$apiKey = new ApiKey();
$repo = $this->prophesize(TagRepository::class);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->expectException(TagNotFoundException::class);
$tagExists->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
}
/**
* @test
* @dataProvider provideAdminApiKeys
*/
public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
$tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$spec = $apiKey === null ? null : $apiKey->spec();
$list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list);
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
$tagExists->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
} }

View file

@ -66,8 +66,8 @@ return [
Service\ShortUrl\ShortUrlResolver::class, Service\ShortUrl\ShortUrlResolver::class,
ShortUrlDataTransformer::class, ShortUrlDataTransformer::class,
], ],
Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class], Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class], Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class], Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],

View file

@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
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\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@ -21,11 +21,11 @@ class ShortUrlVisitsAction extends AbstractRestAction
protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; protected const ROUTE_PATH = '/short-urls/{shortCode}/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private VisitsTrackerInterface $visitsTracker; private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsTrackerInterface $visitsTracker) public function __construct(VisitsStatsHelperInterface $visitsHelper)
{ {
$this->visitsTracker = $visitsTracker; $this->visitsHelper = $visitsHelper;
} }
public function handle(Request $request): Response public function handle(Request $request): Response
@ -33,7 +33,7 @@ class ShortUrlVisitsAction extends AbstractRestAction
$identifier = ShortUrlIdentifier::fromApiRequest($request); $identifier = ShortUrlIdentifier::fromApiRequest($request);
$params = VisitsParams::fromRawData($request->getQueryParams()); $params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsTracker->info($identifier, $params, $apiKey); $visits = $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey);
return new JsonResponse([ return new JsonResponse([
'visits' => $this->serializePaginator($visits), 'visits' => $this->serializePaginator($visits),

View file

@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@ -20,11 +20,11 @@ class TagVisitsAction extends AbstractRestAction
protected const ROUTE_PATH = '/tags/{tag}/visits'; protected const ROUTE_PATH = '/tags/{tag}/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private VisitsTrackerInterface $visitsTracker; private VisitsStatsHelperInterface $visitsHelper;
public function __construct(VisitsTrackerInterface $visitsTracker) public function __construct(VisitsStatsHelperInterface $visitsHelper)
{ {
$this->visitsTracker = $visitsTracker; $this->visitsHelper = $visitsHelper;
} }
public function handle(Request $request): Response public function handle(Request $request): Response
@ -32,7 +32,7 @@ class TagVisitsAction extends AbstractRestAction
$tag = $request->getAttribute('tag', ''); $tag = $request->getAttribute('tag', '');
$params = VisitsParams::fromRawData($request->getQueryParams()); $params = VisitsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
$visits = $this->visitsTracker->visitsForTag($tag, $params, $apiKey); $visits = $this->visitsHelper->visitsForTag($tag, $params, $apiKey);
return new JsonResponse([ return new JsonResponse([
'visits' => $this->serializePaginator($visits), 'visits' => $this->serializePaginator($visits),

View file

@ -16,7 +16,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
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\Service\VisitsTracker; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -25,19 +25,19 @@ class ShortUrlVisitsActionTest extends TestCase
use ProphecyTrait; use ProphecyTrait;
private ShortUrlVisitsAction $action; private ShortUrlVisitsAction $action;
private ObjectProphecy $visitsTracker; private ObjectProphecy $visitsHelper;
public function setUp(): void public function setUp(): void
{ {
$this->visitsTracker = $this->prophesize(VisitsTracker::class); $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal()); $this->action = new ShortUrlVisitsAction($this->visitsHelper->reveal());
} }
/** @test */ /** @test */
public function providingCorrectShortCodeReturnsVisits(): void public function providingCorrectShortCodeReturnsVisits(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->visitsTracker->info( $this->visitsHelper->visitsForShortUrl(
new ShortUrlIdentifier($shortCode), new ShortUrlIdentifier($shortCode),
Argument::type(VisitsParams::class), Argument::type(VisitsParams::class),
Argument::type(ApiKey::class), Argument::type(ApiKey::class),
@ -52,7 +52,7 @@ class ShortUrlVisitsActionTest extends TestCase
public function paramsAreReadFromQuery(): void public function paramsAreReadFromQuery(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams( $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(
new DateRange(null, Chronos::parse('2016-01-01 00:00:00')), new DateRange(null, Chronos::parse('2016-01-01 00:00:00')),
3, 3,
10, 10,

View file

@ -12,7 +12,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -21,12 +21,12 @@ class TagVisitsActionTest extends TestCase
use ProphecyTrait; use ProphecyTrait;
private TagVisitsAction $action; private TagVisitsAction $action;
private ObjectProphecy $visitsTracker; private ObjectProphecy $visitsHelper;
protected function setUp(): void protected function setUp(): void
{ {
$this->visitsTracker = $this->prophesize(VisitsTracker::class); $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->action = new TagVisitsAction($this->visitsTracker->reveal()); $this->action = new TagVisitsAction($this->visitsHelper->reveal());
} }
/** @test */ /** @test */
@ -34,7 +34,7 @@ class TagVisitsActionTest extends TestCase
{ {
$tag = 'foo'; $tag = 'foo';
$apiKey = new ApiKey(); $apiKey = new ApiKey();
$getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn(
new Paginator(new ArrayAdapter([])), new Paginator(new ArrayAdapter([])),
); );