diff --git a/CHANGELOG.md b/CHANGELOG.md index 407e81d5..0fb1e3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 3e8530b6..3f6e27e6 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -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 diff --git a/composer.json b/composer.json index 80ad59be..3b79c514 100644 --- a/composer.json +++ b/composer.json @@ -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 + } } } diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php index 49a3e944..a3964e71 100644 --- a/config/autoload/delete_short_urls.global.php +++ b/config/autoload/delete_short_urls.global.php @@ -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'); diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index c67809d2..fbeb5ab6 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -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'); diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index 64127173..fd11e52a 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index 60054147..16fdbbca 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -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; diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index aff8c6ee..7eb356ab 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -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'); diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 5f528620..7940ad18 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -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; diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index b08dccf2..adf304c8 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -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 [ diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 18f2719e..e38b9c25 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -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; diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php index fbcb5846..871ac531 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -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'); diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index a6c6d5f0..55397e27 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -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 [ diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index f7159ed7..d5a6fd55 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -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; diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index 26fe4639..2dc23890 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 1e5df0a3..d5b4bfe5 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -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; diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index 585d3eb2..fb946028 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -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'); diff --git a/config/config.php b/config/config.php index c62828f3..330cd836 100644 --- a/config/config.php +++ b/config/config.php @@ -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; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 0898c732..41320c89 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -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, ], ], diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index b4fca99c..187f184d 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -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 + } } } } diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 9eebe36f..7d21613d 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -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', '-', '-']]; } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index f79aa03d..879b2eb7 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -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(); diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php new file mode 100644 index 00000000..ae107fdc --- /dev/null +++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index dd5a656d..718a4bc5 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -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; diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index d21122d0..66b94a33 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -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']), ); } diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 924706ff..9cbea269 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -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; } diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php new file mode 100644 index 00000000..3c4e79d2 --- /dev/null +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -0,0 +1,38 @@ +limit; + } + + public function offset(): ?int + { + return $this->offset; + } + + public function searchTerm(): ?string + { + return $this->searchTerm; + } + + public function apiKey(): ?ApiKey + { + return $this->apiKey; + } +} diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php new file mode 100644 index 00000000..7fd12348 --- /dev/null +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -0,0 +1,29 @@ +searchTerm; + } +} diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php new file mode 100644 index 00000000..f6331f5e --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -0,0 +1,33 @@ +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), + )); + } +} diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php new file mode 100644 index 00000000..a92a190a --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php @@ -0,0 +1,17 @@ +repo->findTagsWithInfo( + new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey), + ); + } +} diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php new file mode 100644 index 00000000..5b1da6f7 --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -0,0 +1,28 @@ +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)); + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index c9248520..40eb413f 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -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()); } /** diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index a1aa6122..284fc341 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -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 diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 382e58dd..b5ca98ea 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -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; diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 8cb161d4..0001889b 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -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; diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 92498d9a..d8635b96 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -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 */ diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index c78583af..475cf374 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Repository; +namespace ShlinkioDbTest\Shlink\Core\Repository; use Cake\Chronos\Chronos; use ReflectionObject; diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php new file mode 100644 index 00000000..62d515ab --- /dev/null +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -0,0 +1,52 @@ +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]; + } +} diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php new file mode 100644 index 00000000..2fc354ba --- /dev/null +++ b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php @@ -0,0 +1,48 @@ +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(); + } +} diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php new file mode 100644 index 00000000..4cbfd703 --- /dev/null +++ b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -0,0 +1,37 @@ +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(); + } +} diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php similarity index 72% rename from module/Core/test/Service/Tag/TagServiceTest.php rename to module/Core/test/Tag/TagServiceTest.php index ed8cba29..0eb59df0 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -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, + ]; } /** diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 3d34bd19..c4e0e0de 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -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]); } } diff --git a/module/Rest/test-api/Action/ListTagsTest.php b/module/Rest/test-api/Action/ListTagsTest.php index d82a4f8e..a1dade87 100644 --- a/module/Rest/test-api/Action/ListTagsTest.php +++ b/module/Rest/test-api/Action/ListTagsTest.php @@ -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, + ], ]]; } } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 8b7378fd..504f7b4f 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -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();