Merge pull request #1302 from acelaya-forks/feature/paginated-tags

Feature/paginated tags
This commit is contained in:
Alejandro Celaya 2022-01-06 11:54:30 +01:00 committed by GitHub
commit ead8cc6cec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 705 additions and 129 deletions

View file

@ -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.

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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');

View file

@ -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');

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use function Shlinkio\Shlink\Config\env;
return [

View file

@ -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;

View file

@ -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');

View file

@ -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;

View file

@ -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 [

View file

@ -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;

View file

@ -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');

View file

@ -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 [

View file

@ -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;

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
use function Shlinkio\Shlink\Common\env;
use function Shlinkio\Shlink\Config\env;
return [

View file

@ -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;

View file

@ -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');

View file

@ -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;

View file

@ -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,
],
],

View file

@ -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
}
}
}
}

View file

@ -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', '-', '-']];
}

View file

@ -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();

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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']),
);
}

View file

@ -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;
}

View 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;
}
}

View 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;
}
}

View file

@ -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),
));
}
}

View file

@ -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),
);
}
}

View file

@ -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));
}
}

View file

@ -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());
}
/**

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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 */

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
namespace ShlinkioDbTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use ReflectionObject;

View file

@ -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];
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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,
];
}
/**

View file

@ -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]);
}
}

View file

@ -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,
],
]];
}
}

View file

@ -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();