mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 15:59:56 +03:00
Merge pull request #1302 from acelaya-forks/feature/paginated-tags
Feature/paginated tags
This commit is contained in:
commit
ead8cc6cec
44 changed files with 705 additions and 129 deletions
|
@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
|
||||
The `short-urls:list` command now accepts a `-i`/`--including-all-tags` flag which behaves the same.
|
||||
|
||||
* [#1273](https://github.com/shlinkio/shlink/issues/1273) Added support for pagination in tags lists.
|
||||
|
||||
For backwards compatibility, lists continue returning all items by default, but the `GET /tags` endpoint now supports `page` and `itemsPerPage` query params, to make sure only a subset of the tags is returned.
|
||||
|
||||
This is supported both when invoking the endpoint with and without `withStats=true`.
|
||||
|
||||
Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned.
|
||||
|
||||
### Changed
|
||||
* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% the original size.
|
||||
* [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4.
|
||||
|
|
|
@ -4,7 +4,10 @@ export DB_DRIVER=postgres
|
|||
export TEST_ENV=api
|
||||
export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"}
|
||||
|
||||
# Reset logs
|
||||
rm -rf data/log/api-tests
|
||||
mkdir data/log/api-tests
|
||||
touch data/log/api-tests/output.log
|
||||
|
||||
# Try to stop server just in case it hanged in last execution
|
||||
vendor/bin/laminas mezzio:swoole:stop
|
||||
|
|
|
@ -48,8 +48,8 @@
|
|||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^1.0",
|
||||
"ramsey/uuid": "^4.2",
|
||||
"shlinkio/shlink-common": "^4.2.1",
|
||||
"shlinkio/shlink-config": "^1.4",
|
||||
"shlinkio/shlink-common": "dev-main#0d476fd as 4.3",
|
||||
"shlinkio/shlink-config": "^1.5",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.3",
|
||||
"shlinkio/shlink-importer": "^2.5",
|
||||
"shlinkio/shlink-installer": "dev-develop#a008036 as 7.0",
|
||||
|
@ -95,10 +95,8 @@
|
|||
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
|
||||
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
|
||||
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
|
||||
"ShlinkioTest\\Shlink\\Core\\": [
|
||||
"module/Core/test",
|
||||
"module/Core/test-db"
|
||||
]
|
||||
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
|
||||
"ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db"
|
||||
},
|
||||
"files": [
|
||||
"config/test/constants.php"
|
||||
|
@ -192,6 +190,12 @@
|
|||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"platform-check": false
|
||||
"platform-check": false,
|
||||
"allow-plugins": {
|
||||
"composer/package-versions-deprecated": true,
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true,
|
||||
"infection/extension-installer": true,
|
||||
"veewee/composer-run-parallel": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return (static function (): array {
|
||||
$threshold = env('DELETE_SHORT_URL_THRESHOLD');
|
||||
|
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
|
||||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return (static function (): array {
|
||||
$driver = env('DB_DRIVER');
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return [
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ use Predis\ClientInterface as PredisClient;
|
|||
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
|||
use Symfony\Component\Mercure\Hub;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return (static function (): array {
|
||||
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
|
||||
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
|
||||
|
|
|
@ -6,7 +6,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
|||
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
|
||||
use PhpAmqpLib\Connection\AMQPStreamConnection;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return [
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
|
||||
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return (static function (): array {
|
||||
$redisServers = env('REDIS_SERVERS');
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
use Mezzio\Router\FastRouteRouter;
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return [
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return [
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
return (static function (): array {
|
||||
$webhooks = env('VISITS_WEBHOOKS');
|
||||
|
|
|
@ -11,7 +11,7 @@ use Mezzio\ProblemDetails;
|
|||
use Mezzio\Swoole;
|
||||
|
||||
use function class_exists;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
|
||||
use const PHP_SAPI;
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ use SebastianBergmann\CodeCoverage\Report\PHP;
|
|||
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
|
||||
|
||||
use function Laminas\Stratigility\middleware;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
use function Shlinkio\Shlink\Config\env;
|
||||
use function sprintf;
|
||||
use function sys_get_temp_dir;
|
||||
|
||||
|
@ -109,6 +109,7 @@ return [
|
|||
'process-name' => 'shlink_test',
|
||||
'options' => [
|
||||
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
|
||||
'log_file' => __DIR__ . '/../../data/log/api-tests/output.log',
|
||||
'enable_coroutine' => false,
|
||||
],
|
||||
],
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
},
|
||||
{
|
||||
"name": "withStats",
|
||||
"description": "Whether you want to include also a list with general stats by tag or not.",
|
||||
"description": "Whether you want to include also a list with general stats by tag or not. Defaults to false.",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
|
@ -27,6 +27,33 @@
|
|||
"false"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "searchTerm",
|
||||
"in": "query",
|
||||
"description": "A query used to filter results by searching for it on the tag name.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -53,6 +80,9 @@
|
|||
"items": {
|
||||
"$ref": "../definitions/TagInfo.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -67,7 +97,14 @@
|
|||
"php",
|
||||
"shlink",
|
||||
"tech"
|
||||
]
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 10,
|
||||
"itemsPerPage": 4,
|
||||
"itemsInCurrentPage": 4,
|
||||
"totalItems": 38
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -89,7 +126,14 @@
|
|||
"shortUrlsCount": 7,
|
||||
"visitsCount": 1087
|
||||
}
|
||||
]
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 5,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 2,
|
||||
"totalItems": 42
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
|||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
@ -38,7 +39,7 @@ class ListTagsCommand extends Command
|
|||
|
||||
private function getTagsRows(): array
|
||||
{
|
||||
$tags = $this->tagService->tagsInfo();
|
||||
$tags = $this->tagService->tagsInfo(TagsParams::fromRawData([]))->getCurrentPageResults();
|
||||
if (empty($tags)) {
|
||||
return [['No tags found', '-', '-']];
|
||||
}
|
||||
|
|
|
@ -4,9 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
|
@ -29,7 +32,7 @@ class ListTagsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function noTagsPrintsEmptyMessage(): void
|
||||
{
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
|
||||
$tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
@ -41,10 +44,10 @@ class ListTagsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function listOfTagsIsPrinted(): void
|
||||
{
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([
|
||||
$tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([
|
||||
new TagInfo(new Tag('foo'), 10, 2),
|
||||
new TagInfo(new Tag('bar'), 7, 32),
|
||||
]);
|
||||
])));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
|
||||
abstract class AbstractInfinitePaginableListParams
|
||||
{
|
||||
private const FIRST_PAGE = 1;
|
||||
|
||||
private int $page;
|
||||
private int $itemsPerPage;
|
||||
|
||||
protected function __construct(?int $page, ?int $itemsPerPage)
|
||||
{
|
||||
$this->page = $this->determinePage($page);
|
||||
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
|
||||
}
|
||||
|
||||
private function determinePage(?int $page): int
|
||||
{
|
||||
return $page === null || $page <= 0 ? self::FIRST_PAGE : $page;
|
||||
}
|
||||
|
||||
private function determineItemsPerPage(?int $itemsPerPage): int
|
||||
{
|
||||
return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage;
|
||||
}
|
||||
|
||||
public function getPage(): int
|
||||
{
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): int
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
}
|
|
@ -4,49 +4,29 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
|
||||
|
||||
final class VisitsParams
|
||||
final class VisitsParams extends AbstractInfinitePaginableListParams
|
||||
{
|
||||
private const FIRST_PAGE = 1;
|
||||
|
||||
private DateRange $dateRange;
|
||||
private int $page;
|
||||
private int $itemsPerPage;
|
||||
|
||||
public function __construct(
|
||||
?DateRange $dateRange = null,
|
||||
int $page = self::FIRST_PAGE,
|
||||
?int $page = null,
|
||||
?int $itemsPerPage = null,
|
||||
private bool $excludeBots = false,
|
||||
) {
|
||||
parent::__construct($page, $itemsPerPage);
|
||||
$this->dateRange = $dateRange ?? DateRange::emptyInstance();
|
||||
$this->page = $this->determinePage($page);
|
||||
$this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
|
||||
}
|
||||
|
||||
private function determinePage(int $page): int
|
||||
{
|
||||
return $page > 0 ? $page : self::FIRST_PAGE;
|
||||
}
|
||||
|
||||
private function determineItemsPerPage(?int $itemsPerPage): int
|
||||
{
|
||||
if ($itemsPerPage !== null && $itemsPerPage < 0) {
|
||||
return Paginator::ALL_ITEMS;
|
||||
}
|
||||
|
||||
return $itemsPerPage ?? Paginator::ALL_ITEMS;
|
||||
}
|
||||
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
return new self(
|
||||
parseDateRangeFromQuery($query, 'startDate', 'endDate'),
|
||||
(int) ($query['page'] ?? self::FIRST_PAGE),
|
||||
isset($query['page']) ? (int) $query['page'] : null,
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||
isset($query['excludeBots']),
|
||||
);
|
||||
|
@ -57,16 +37,6 @@ final class VisitsParams
|
|||
return $this->dateRange;
|
||||
}
|
||||
|
||||
public function getPage(): int
|
||||
{
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): int
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function excludeBots(): bool
|
||||
{
|
||||
return $this->excludeBots;
|
||||
|
|
|
@ -8,6 +8,7 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
|||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
@ -32,24 +33,31 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
|
|||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(?ApiKey $apiKey = null): array
|
||||
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t');
|
||||
$qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
|
||||
->leftJoin('t.shortUrls', 's')
|
||||
->leftJoin('s.visits', 'v')
|
||||
->groupBy('t')
|
||||
->orderBy('t.name', 'ASC');
|
||||
->orderBy('t.name', 'ASC')
|
||||
->setMaxResults($filtering?->limit())
|
||||
->setFirstResult($filtering?->offset());
|
||||
|
||||
$searchTerm = $filtering?->searchTerm();
|
||||
if ($searchTerm !== null) {
|
||||
$qb->andWhere($qb->expr()->like('t.name', ':searchPattern'))
|
||||
->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||
}
|
||||
|
||||
$apiKey = $filtering?->apiKey();
|
||||
if ($apiKey !== null) {
|
||||
$this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't');
|
||||
}
|
||||
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return map(
|
||||
$query->getResult(),
|
||||
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
|
||||
$qb->getQuery()->getResult(),
|
||||
static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository;
|
|||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
|
||||
|
@ -16,7 +17,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe
|
|||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(?ApiKey $apiKey = null): array;
|
||||
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array;
|
||||
|
||||
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool;
|
||||
}
|
||||
|
|
38
module/Core/src/Tag/Model/TagsListFiltering.php
Normal file
38
module/Core/src/Tag/Model/TagsListFiltering.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
final class TagsListFiltering
|
||||
{
|
||||
public function __construct(
|
||||
private ?int $limit = null,
|
||||
private ?int $offset = null,
|
||||
private ?string $searchTerm = null,
|
||||
private ?ApiKey $apiKey = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function limit(): ?int
|
||||
{
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
public function offset(): ?int
|
||||
{
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
return $this->searchTerm;
|
||||
}
|
||||
|
||||
public function apiKey(): ?ApiKey
|
||||
{
|
||||
return $this->apiKey;
|
||||
}
|
||||
}
|
29
module/Core/src/Tag/Model/TagsParams.php
Normal file
29
module/Core/src/Tag/Model/TagsParams.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams;
|
||||
|
||||
final class TagsParams extends AbstractInfinitePaginableListParams
|
||||
{
|
||||
private function __construct(private ?string $searchTerm, ?int $page, ?int $itemsPerPage)
|
||||
{
|
||||
parent::__construct($page, $itemsPerPage);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
return new self(
|
||||
$query['searchTerm'] ?? null,
|
||||
isset($query['page']) ? (int) $query['page'] : null,
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
return $this->searchTerm;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
abstract class AbstractTagsPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepositoryInterface $repo,
|
||||
protected TagsParams $params,
|
||||
protected ?ApiKey $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getNbResults(): int
|
||||
{
|
||||
return (int) $this->repo->matchSingleScalarResult(Spec::andX(
|
||||
// FIXME I don't think using Spec::selectNew is the correct thing here, ideally it should be Spec::select,
|
||||
// but seems to be the only way to use Spec::COUNT(...)
|
||||
Spec::selectNew(Tag::class, Spec::COUNT('id', true)),
|
||||
new WithApiKeySpecsEnsuringJoin($this->apiKey),
|
||||
));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
|
||||
class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repo->findTagsWithInfo(
|
||||
new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
|
||||
class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter
|
||||
{
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
$conditions = [
|
||||
new WithApiKeySpecsEnsuringJoin($this->apiKey),
|
||||
Spec::orderBy('name'),
|
||||
Spec::limit($length),
|
||||
Spec::offset($offset),
|
||||
];
|
||||
|
||||
$searchTerm = $this->params->searchTerm();
|
||||
if ($searchTerm !== null) {
|
||||
$conditions[] = Spec::like('name', $searchTerm);
|
||||
}
|
||||
|
||||
return $this->repo->match(Spec::andX(...$conditions));
|
||||
}
|
||||
}
|
|
@ -5,7 +5,8 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
|
@ -14,7 +15,9 @@ use Shlinkio\Shlink\Core\Repository\TagRepository;
|
|||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
|
@ -24,26 +27,30 @@ class TagService implements TagServiceInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @return Tag[]
|
||||
* @return Tag[]|Paginator
|
||||
*/
|
||||
public function listTags(?ApiKey $apiKey = null): array
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepository $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
return $repo->match(Spec::andX(
|
||||
Spec::orderBy('name'),
|
||||
new WithApiKeySpecsEnsuringJoin($apiKey),
|
||||
));
|
||||
return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
* @return TagInfo[]|Paginator
|
||||
*/
|
||||
public function tagsInfo(?ApiKey $apiKey = null): array
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
return $repo->findTagsWithInfo($apiKey);
|
||||
return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
}
|
||||
|
||||
private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
|
||||
{
|
||||
return (new Paginator($adapter))
|
||||
->setMaxPerPage($params->getItemsPerPage())
|
||||
->setCurrentPage($params->getPage());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,25 +4,27 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface TagServiceInterface
|
||||
{
|
||||
/**
|
||||
* @return Tag[]
|
||||
* @return Tag[]|Paginator
|
||||
*/
|
||||
public function listTags(?ApiKey $apiKey = null): array;
|
||||
public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
* @return TagInfo[]|Paginator
|
||||
*/
|
||||
public function tagsInfo(?ApiKey $apiKey = null): array;
|
||||
public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Domain\Repository;
|
||||
namespace ShlinkioDbTest\Shlink\Core\Domain\Repository;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
namespace ShlinkioDbTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
namespace ShlinkioDbTest\Shlink\Core\Repository;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
|
@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
|||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
@ -50,8 +52,11 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
self::assertEquals(2, $this->repo->deleteByName($toDelete));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properTagsInfoIsReturned(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFilterings
|
||||
*/
|
||||
public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void
|
||||
{
|
||||
$names = ['foo', 'bar', 'baz', 'another'];
|
||||
foreach ($names as $name) {
|
||||
|
@ -74,24 +79,81 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findTagsWithInfo();
|
||||
$result = $this->repo->findTagsWithInfo($filtering);
|
||||
|
||||
self::assertCount(4, $result);
|
||||
self::assertEquals(0, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(0, $result[0]->visitsCount());
|
||||
self::assertEquals($names[3], $result[0]->tag()->__toString());
|
||||
$asserts($result, $names);
|
||||
}
|
||||
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($names[1], $result[1]->tag()->__toString());
|
||||
public function provideFilterings(): iterable
|
||||
{
|
||||
$noFiltersAsserts = static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(4, $result);
|
||||
self::assertEquals(0, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(0, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[3], $result[0]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[2]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[2]->visitsCount());
|
||||
self::assertEquals($names[2], $result[2]->tag()->__toString());
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($tagNames[1], $result[1]->tag()->__toString());
|
||||
|
||||
self::assertEquals(2, $result[3]->shortUrlsCount());
|
||||
self::assertEquals(4, $result[3]->visitsCount());
|
||||
self::assertEquals($names[0], $result[3]->tag()->__toString());
|
||||
self::assertEquals(1, $result[2]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[2]->visitsCount());
|
||||
self::assertEquals($tagNames[2], $result[2]->tag()->__toString());
|
||||
|
||||
self::assertEquals(2, $result[3]->shortUrlsCount());
|
||||
self::assertEquals(4, $result[3]->visitsCount());
|
||||
self::assertEquals($tagNames[0], $result[3]->tag()->__toString());
|
||||
};
|
||||
|
||||
yield 'no filter' => [null, $noFiltersAsserts];
|
||||
yield 'empty filter' => [new TagsListFiltering(), $noFiltersAsserts];
|
||||
yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(2, $result);
|
||||
self::assertEquals(0, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(0, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[3], $result[0]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($tagNames[1], $result[1]->tag()->__toString());
|
||||
}];
|
||||
yield 'offset' => [new TagsListFiltering(null, 3), static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(1, $result);
|
||||
self::assertEquals(2, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(4, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[0], $result[0]->tag()->__toString());
|
||||
}];
|
||||
yield 'limit and offset' => [
|
||||
new TagsListFiltering(2, 1),
|
||||
static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(2, $result);
|
||||
self::assertEquals(1, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[1], $result[0]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
|
||||
},
|
||||
];
|
||||
yield 'search term' => [
|
||||
new TagsListFiltering(null, null, 'ba'),
|
||||
static function (array $result, array $tagNames): void {
|
||||
/** @var TagInfo[] $result */
|
||||
self::assertCount(2, $result);
|
||||
self::assertEquals(1, $result[0]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[0]->visitsCount());
|
||||
self::assertEquals($tagNames[1], $result[0]->tag()->__toString());
|
||||
|
||||
self::assertEquals(1, $result[1]->shortUrlsCount());
|
||||
self::assertEquals(3, $result[1]->visitsCount());
|
||||
self::assertEquals($tagNames[2], $result[1]->tag()->__toString());
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
namespace ShlinkioDbTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use ReflectionObject;
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioDbTest\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
class TagsPaginatorAdapterTest extends DatabaseTestCase
|
||||
{
|
||||
private TagRepository $repo;
|
||||
|
||||
protected function beforeEach(): void
|
||||
{
|
||||
$this->repo = $this->getEntityManager()->getRepository(Tag::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideFilters
|
||||
*/
|
||||
public function expectedListOfTagsIsReturned(?string $searchTerm, int $offset, int $length, int $expected): void
|
||||
{
|
||||
$names = ['foo', 'bar', 'baz', 'another'];
|
||||
foreach ($names as $name) {
|
||||
$this->getEntityManager()->persist(new Tag($name));
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData(['searchTerm' => $searchTerm]), null);
|
||||
|
||||
self::assertCount($expected, $adapter->getSlice($offset, $length));
|
||||
self::assertEquals(4, $adapter->getNbResults());
|
||||
}
|
||||
|
||||
public function provideFilters(): iterable
|
||||
{
|
||||
yield [null, 0, 10, 4];
|
||||
yield [null, 2, 10, 2];
|
||||
yield [null, 1, 3, 3];
|
||||
yield [null, 3, 3, 1];
|
||||
yield [null, 0, 2, 2];
|
||||
yield ['ba', 0, 10, 2];
|
||||
yield ['ba', 0, 1, 1];
|
||||
yield ['foo', 0, 10, 1];
|
||||
yield ['a', 0, 10, 3];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
|
||||
|
||||
class TagsInfoPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private TagsInfoPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(TagRepositoryInterface::class);
|
||||
$this->adapter = new TagsInfoPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function getSliceIsDelegatedToRepository(): void
|
||||
{
|
||||
$findTags = $this->repo->findTagsWithInfo(Argument::cetera())->willReturn([]);
|
||||
|
||||
$this->adapter->getSlice(1, 1);
|
||||
|
||||
$findTags->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function getNbResultsIsDelegatedToRepository(): void
|
||||
{
|
||||
$match = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(3);
|
||||
|
||||
$result = $this->adapter->getNbResults();
|
||||
|
||||
self::assertEquals(3, $result);
|
||||
$match->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
|
||||
|
||||
class TagsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private TagsPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(TagRepositoryInterface::class);
|
||||
$this->adapter = new TagsPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function getSliceDelegatesToRepository(): void
|
||||
{
|
||||
$match = $this->repo->match(Argument::cetera())->willReturn([]);
|
||||
|
||||
$this->adapter->getSlice(1, 1);
|
||||
|
||||
$match->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Service\Tag;
|
||||
namespace ShlinkioTest\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
|||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
|
@ -46,27 +48,63 @@ class TagServiceTest extends TestCase
|
|||
$expected = [new Tag('foo'), new Tag('bar')];
|
||||
|
||||
$match = $this->repo->match(Argument::cetera())->willReturn($expected);
|
||||
$count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
|
||||
|
||||
$result = $this->service->listTags();
|
||||
$result = $this->service->listTags(TagsParams::fromRawData([]));
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
self::assertEquals($expected, $result->getCurrentPageResults());
|
||||
$match->shouldHaveBeenCalled();
|
||||
$count->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAdminApiKeys
|
||||
* @dataProvider provideApiKeysAndSearchTerm
|
||||
*/
|
||||
public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void
|
||||
{
|
||||
public function tagsInfoDelegatesOnRepository(
|
||||
?ApiKey $apiKey,
|
||||
TagsParams $params,
|
||||
TagsListFiltering $expectedFiltering,
|
||||
int $countCalls,
|
||||
): void {
|
||||
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
|
||||
|
||||
$find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected);
|
||||
$find = $this->repo->findTagsWithInfo($expectedFiltering)->willReturn($expected);
|
||||
$count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
|
||||
|
||||
$result = $this->service->tagsInfo($apiKey);
|
||||
$result = $this->service->tagsInfo($params, $apiKey);
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
self::assertEquals($expected, $result->getCurrentPageResults());
|
||||
$find->shouldHaveBeenCalledOnce();
|
||||
$count->shouldHaveBeenCalledTimes($countCalls);
|
||||
}
|
||||
|
||||
public function provideApiKeysAndSearchTerm(): iterable
|
||||
{
|
||||
yield 'no API key, no filter' => [
|
||||
null,
|
||||
TagsParams::fromRawData([]),
|
||||
new TagsListFiltering(2, 0, null, null),
|
||||
1,
|
||||
];
|
||||
yield 'admin API key, no filter' => [
|
||||
$apiKey = ApiKey::create(),
|
||||
TagsParams::fromRawData([]),
|
||||
new TagsListFiltering(2, 0, null, $apiKey),
|
||||
1,
|
||||
];
|
||||
yield 'no API key, search term' => [
|
||||
null,
|
||||
TagsParams::fromRawData(['searchTerm' => $searchTerm = 'foobar']),
|
||||
new TagsListFiltering(2, 0, $searchTerm, null),
|
||||
1,
|
||||
];
|
||||
yield 'admin API key, limits' => [
|
||||
$apiKey = ApiKey::create(),
|
||||
TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]),
|
||||
new TagsListFiltering(1, 0, null, $apiKey),
|
||||
0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
|
@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
|
||||
|
@ -16,6 +18,8 @@ use function Functional\map;
|
|||
|
||||
class ListTagsAction extends AbstractRestAction
|
||||
{
|
||||
use PagerfantaUtilsTrait;
|
||||
|
||||
protected const ROUTE_PATH = '/tags';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
|
@ -28,23 +32,18 @@ class ListTagsAction extends AbstractRestAction
|
|||
$query = $request->getQueryParams();
|
||||
$withStats = ($query['withStats'] ?? null) === 'true';
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$params = TagsParams::fromRawData($query);
|
||||
|
||||
if (! $withStats) {
|
||||
return new JsonResponse([
|
||||
'tags' => [
|
||||
'data' => $this->tagService->listTags($apiKey),
|
||||
],
|
||||
'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
|
||||
]);
|
||||
}
|
||||
|
||||
$tagsInfo = $this->tagService->tagsInfo($apiKey);
|
||||
$data = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString());
|
||||
$tagsInfo = $this->tagService->tagsInfo($params, $apiKey);
|
||||
$rawTags = $this->serializePaginator($tagsInfo, null, 'stats');
|
||||
$rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString());
|
||||
|
||||
return new JsonResponse([
|
||||
'tags' => [
|
||||
'data' => $data,
|
||||
'stats' => $tagsInfo,
|
||||
],
|
||||
]);
|
||||
return new JsonResponse(['tags' => $rawTags]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,23 @@ class ListTagsTest extends ApiTestCase
|
|||
{
|
||||
yield 'admin API key without stats' => ['valid_api_key', [], [
|
||||
'data' => ['bar', 'baz', 'foo'],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 3,
|
||||
'itemsInCurrentPage' => 3,
|
||||
'totalItems' => 3,
|
||||
],
|
||||
]];
|
||||
yield 'admin api key with pagination' => ['valid_api_key', ['page' => 2, 'itemsPerPage' => 2], [
|
||||
'data' => ['foo'],
|
||||
'pagination' => [
|
||||
'currentPage' => 2,
|
||||
'pagesCount' => 2,
|
||||
'itemsPerPage' => 2,
|
||||
'itemsInCurrentPage' => 1,
|
||||
'totalItems' => 3,
|
||||
],
|
||||
]];
|
||||
yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [
|
||||
'data' => ['bar', 'baz', 'foo'],
|
||||
|
@ -45,10 +62,50 @@ class ListTagsTest extends ApiTestCase
|
|||
'visitsCount' => 5,
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 3,
|
||||
'itemsInCurrentPage' => 3,
|
||||
'totalItems' => 3,
|
||||
],
|
||||
]];
|
||||
yield 'admin API key with pagination and stats' => ['valid_api_key', [
|
||||
'withStats' => 'true',
|
||||
'page' => 1,
|
||||
'itemsPerPage' => 2,
|
||||
], [
|
||||
'data' => ['bar', 'baz'],
|
||||
'stats' => [
|
||||
[
|
||||
'tag' => 'bar',
|
||||
'shortUrlsCount' => 1,
|
||||
'visitsCount' => 2,
|
||||
],
|
||||
[
|
||||
'tag' => 'baz',
|
||||
'shortUrlsCount' => 0,
|
||||
'visitsCount' => 0,
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 2,
|
||||
'itemsPerPage' => 2,
|
||||
'itemsInCurrentPage' => 2,
|
||||
'totalItems' => 3,
|
||||
],
|
||||
]];
|
||||
|
||||
yield 'author API key without stats' => ['author_api_key', [], [
|
||||
'data' => ['bar', 'foo'],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 2,
|
||||
'itemsInCurrentPage' => 2,
|
||||
'totalItems' => 2,
|
||||
],
|
||||
]];
|
||||
yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [
|
||||
'data' => ['bar', 'foo'],
|
||||
|
@ -64,10 +121,24 @@ class ListTagsTest extends ApiTestCase
|
|||
'visitsCount' => 5,
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 2,
|
||||
'itemsInCurrentPage' => 2,
|
||||
'totalItems' => 2,
|
||||
],
|
||||
]];
|
||||
|
||||
yield 'domain API key without stats' => ['domain_api_key', [], [
|
||||
'data' => ['foo'],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 1,
|
||||
'itemsInCurrentPage' => 1,
|
||||
'totalItems' => 1,
|
||||
],
|
||||
]];
|
||||
yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [
|
||||
'data' => ['foo'],
|
||||
|
@ -78,6 +149,13 @@ class ListTagsTest extends ApiTestCase
|
|||
'visitsCount' => 0,
|
||||
],
|
||||
],
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 1,
|
||||
'itemsInCurrentPage' => 1,
|
||||
'totalItems' => 1,
|
||||
],
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,17 +6,21 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
|
|||
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Pagerfanta\Adapter\ArrayAdapter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
use function count;
|
||||
|
||||
class ListTagsActionTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
@ -37,7 +41,10 @@ class ListTagsActionTest extends TestCase
|
|||
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
|
||||
{
|
||||
$tags = [new Tag('foo'), new Tag('bar')];
|
||||
$listTags = $this->tagService->listTags(Argument::type(ApiKey::class))->willReturn($tags);
|
||||
$tagsCount = count($tags);
|
||||
$listTags = $this->tagService->listTags(Argument::any(), Argument::type(ApiKey::class))->willReturn(
|
||||
new Paginator(new ArrayAdapter($tags)),
|
||||
);
|
||||
|
||||
/** @var JsonResponse $resp */
|
||||
$resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query));
|
||||
|
@ -46,6 +53,13 @@ class ListTagsActionTest extends TestCase
|
|||
self::assertEquals([
|
||||
'tags' => [
|
||||
'data' => $tags,
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 10,
|
||||
'itemsInCurrentPage' => $tagsCount,
|
||||
'totalItems' => $tagsCount,
|
||||
],
|
||||
],
|
||||
], $payload);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
|
@ -65,7 +79,10 @@ class ListTagsActionTest extends TestCase
|
|||
new TagInfo(new Tag('foo'), 1, 1),
|
||||
new TagInfo(new Tag('bar'), 3, 10),
|
||||
];
|
||||
$tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats);
|
||||
$itemsCount = count($stats);
|
||||
$tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn(
|
||||
new Paginator(new ArrayAdapter($stats)),
|
||||
);
|
||||
$req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
|
||||
|
||||
/** @var JsonResponse $resp */
|
||||
|
@ -76,6 +93,13 @@ class ListTagsActionTest extends TestCase
|
|||
'tags' => [
|
||||
'data' => ['foo', 'bar'],
|
||||
'stats' => $stats,
|
||||
'pagination' => [
|
||||
'currentPage' => 1,
|
||||
'pagesCount' => 1,
|
||||
'itemsPerPage' => 10,
|
||||
'itemsInCurrentPage' => $itemsCount,
|
||||
'totalItems' => $itemsCount,
|
||||
],
|
||||
],
|
||||
], $payload);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
|
|
Loading…
Add table
Reference in a new issue