From 92a83b82a01dcbfc3003016b0cd23a6d17856081 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Dec 2022 19:37:02 +0100 Subject: [PATCH] Split short URL listing capabilities on its own repo and service --- composer.json | 2 +- module/CLI/config/dependencies.config.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 4 +- .../ShortUrl/ListShortUrlsCommandTest.php | 6 +- module/Core/config/dependencies.config.php | 9 + .../Adapter/ShortUrlRepositoryAdapter.php | 4 +- .../Repository/ShortUrlListRepository.php | 165 +++++++++ .../ShortUrlListRepositoryInterface.php | 19 + .../Repository/ShortUrlRepository.php | 141 -------- .../ShortUrlRepositoryInterface.php | 6 - .../Core/src/ShortUrl/ShortUrlListService.php | 31 ++ .../ShortUrl/ShortUrlListServiceInterface.php | 18 + module/Core/src/ShortUrl/ShortUrlService.php | 21 -- .../src/ShortUrl/ShortUrlServiceInterface.php | 7 - .../Repository/ShortUrlListRepositoryTest.php | 337 ++++++++++++++++++ .../Repository/ShortUrlRepositoryTest.php | 312 ---------------- .../Adapter/ShortUrlRepositoryAdapterTest.php | 6 +- .../test/ShortUrl/ShortUrlListServiceTest.php | 53 +++ .../test/ShortUrl/ShortUrlServiceTest.php | 30 -- module/Rest/config/dependencies.config.php | 5 +- .../Action/ShortUrl/ListShortUrlsAction.php | 6 +- .../ShortUrl/ListShortUrlsActionTest.php | 6 +- 22 files changed, 654 insertions(+), 536 deletions(-) create mode 100644 module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php create mode 100644 module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php create mode 100644 module/Core/src/ShortUrl/ShortUrlListService.php create mode 100644 module/Core/src/ShortUrl/ShortUrlListServiceInterface.php create mode 100644 module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php create mode 100644 module/Core/test/ShortUrl/ShortUrlListServiceTest.php diff --git a/composer.json b/composer.json index 5b58b62b..033920ca 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.5", - "shlinkio/shlink-common": "dev-main#8d06f0e as 5.2", + "shlinkio/shlink-common": "dev-main#e2a5bb7 as 5.2", "shlinkio/shlink-config": "dev-main#96c81fb as 2.3", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "dev-main#c97662b as 5.0", diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 51a3f2d7..177e4c8b 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -84,7 +84,7 @@ return [ ], Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ - ShortUrl\ShortUrlService::class, + ShortUrl\ShortUrlListService::class, ShortUrl\Transformer\ShortUrlDataTransformer::class, ], Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 6e735221..7a9c77af 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -39,7 +39,7 @@ class ListShortUrlsCommand extends Command private readonly EndDateOption $endDateOption; public function __construct( - private readonly ShortUrlServiceInterface $shortUrlService, + private readonly ShortUrlListServiceInterface $shortUrlService, private readonly DataTransformerInterface $transformer, ) { parent::__construct(); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 84b138d4..9b45869f 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -30,11 +30,11 @@ class ListShortUrlsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private MockObject & ShortUrlServiceInterface $shortUrlService; + private MockObject & ShortUrlListServiceInterface $shortUrlService; protected function setUp(): void { - $this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class); + $this->shortUrlService = $this->createMock(ShortUrlListServiceInterface::class); $command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5f51c2d7..708bb8a3 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; @@ -34,6 +35,7 @@ return [ ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class, + ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class, ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class, @@ -44,6 +46,10 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class, + ShortUrl\Repository\ShortUrlListRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], Tag\TagService::class => ConfigAbstractFactory::class, @@ -108,6 +114,9 @@ return [ ShortUrl\ShortUrlResolver::class, ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, + ], + ShortUrl\ShortUrlListService::class => [ + ShortUrl\Repository\ShortUrlListRepository::class, Options\UrlShortenerOptions::class, ], Visit\Geolocation\VisitLocator::class => ['em'], diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index d88d8b81..83ce8bd9 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -8,13 +8,13 @@ use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { public function __construct( - private readonly ShortUrlRepositoryInterface $repository, + private readonly ShortUrlListRepositoryInterface $repository, private readonly ShortUrlsParams $params, private readonly ?ApiKey $apiKey, private readonly string $defaultDomain, diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php new file mode 100644 index 00000000..35d7996f --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -0,0 +1,165 @@ +createListQueryBuilder($filtering); + $qb->select('DISTINCT s') + ->setMaxResults($filtering->limit) + ->setFirstResult($filtering->offset); + + // In case the ordering has been specified, the query could be more complex. Process it + $this->processOrderByForList($qb, $filtering); + + $result = $qb->getQuery()->getResult(); + if ($filtering->orderBy->field === 'visits') { + return array_column($result, 0); + } + + return $result; + } + + private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void + { + // With no explicit order by, fallback to dateCreated-DESC + if (! $filtering->orderBy->hasOrderField()) { + $qb->orderBy('s.dateCreated', 'DESC'); + return; + } + + $fieldName = $filtering->orderBy->field; + $order = $filtering->orderBy->direction; + + if ($fieldName === 'visits') { + // FIXME This query is inefficient. + // Diagnostic: It might need to use a sub-query, as done with the tags list query. + $qb->addSelect('COUNT(DISTINCT v)') + ->leftJoin('s.visits', 'v') + ->groupBy('s') + ->orderBy('COUNT(DISTINCT v)', $order); + } elseif (contains(['longUrl', 'shortCode', 'dateCreated', 'title'], $fieldName)) { + $qb->orderBy('s.' . $fieldName, $order); + } + } + + public function countList(ShortUrlsCountFiltering $filtering): int + { + $qb = $this->createListQueryBuilder($filtering); + $qb->select('COUNT(DISTINCT s)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(ShortUrl::class, 's') + ->where('1=1'); + + $dateRange = $filtering->dateRange; + if ($dateRange?->startDate !== null) { + $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); + $qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + if ($dateRange?->endDate !== null) { + $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); + $qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + + $searchTerm = $filtering->searchTerm; + $tags = $filtering->tags; + // Apply search term to every searchable field if not empty + if (! empty($searchTerm)) { + // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later + if (empty($tags)) { + $qb->leftJoin('s.tags', 't'); + } + + // Apply general search conditions + $conditions = [ + $qb->expr()->like('s.longUrl', ':searchPattern'), + $qb->expr()->like('s.shortCode', ':searchPattern'), + $qb->expr()->like('s.title', ':searchPattern'), + $qb->expr()->like('d.authority', ':searchPattern'), + ]; + + // Include default domain in search if provided + if ($filtering->searchIncludesDefaultDomain) { + $conditions[] = $qb->expr()->isNull('s.domain'); + } + + // Apply tag conditions, only when not filtering by all provided tags + $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + if (empty($tags) || $tagsMode === TagsMode::ANY) { + $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); + } + + $qb->leftJoin('s.domain', 'd') + ->andWhere($qb->expr()->orX(...$conditions)) + ->setParameter('searchPattern', '%' . $searchTerm . '%'); + } + + // Filter by tags if provided + if (! empty($tags)) { + $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + $tagsMode === TagsMode::ANY + ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) + : $this->joinAllTags($qb, $tags); + } + + if ($filtering->excludeMaxVisitsReached) { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.maxVisits'), + $qb->expr()->gt( + 's.maxVisits', + sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class), + ), + )); + } + + if ($filtering->excludePastValidUntil) { + $qb + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.validUntil'), + $qb->expr()->gte('s.validUntil', ':minValidUntil'), + )) + ->setParameter('minValidUntil', Chronos::now()->toDateTimeString()); + } + + $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); + + return $qb; + } + + private function joinAllTags(QueryBuilder $qb, array $tags): void + { + foreach ($tags as $index => $tag) { + $alias = 't_' . $index; + $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index) + ->setParameter('tag' . $index, $tag); + } + } +} diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php new file mode 100644 index 00000000..130e0db7 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -0,0 +1,19 @@ +createListQueryBuilder($filtering); - $qb->select('DISTINCT s') - ->setMaxResults($filtering->limit) - ->setFirstResult($filtering->offset); - - // In case the ordering has been specified, the query could be more complex. Process it - $this->processOrderByForList($qb, $filtering); - - $result = $qb->getQuery()->getResult(); - if ($filtering->orderBy->field === 'visits') { - return array_column($result, 0); - } - - return $result; - } - - private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void - { - // With no explicit order by, fallback to dateCreated-DESC - if (! $filtering->orderBy->hasOrderField()) { - $qb->orderBy('s.dateCreated', 'DESC'); - return; - } - - $fieldName = $filtering->orderBy->field; - $order = $filtering->orderBy->direction; - - if ($fieldName === 'visits') { - // FIXME This query is inefficient. - // Diagnostic: It might need to use a sub-query, as done with the tags list query. - $qb->addSelect('COUNT(DISTINCT v)') - ->leftJoin('s.visits', 'v') - ->groupBy('s') - ->orderBy('COUNT(DISTINCT v)', $order); - } elseif (contains(['longUrl', 'shortCode', 'dateCreated', 'title'], $fieldName)) { - $qb->orderBy('s.' . $fieldName, $order); - } - } - - public function countList(ShortUrlsCountFiltering $filtering): int - { - $qb = $this->createListQueryBuilder($filtering); - $qb->select('COUNT(DISTINCT s)'); - - return (int) $qb->getQuery()->getSingleScalarResult(); - } - - private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder - { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->from(ShortUrl::class, 's') - ->where('1=1'); - - $dateRange = $filtering->dateRange; - if ($dateRange?->startDate !== null) { - $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); - } - if ($dateRange?->endDate !== null) { - $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); - } - - $searchTerm = $filtering->searchTerm; - $tags = $filtering->tags; - // Apply search term to every searchable field if not empty - if (! empty($searchTerm)) { - // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later - if (empty($tags)) { - $qb->leftJoin('s.tags', 't'); - } - - // Apply general search conditions - $conditions = [ - $qb->expr()->like('s.longUrl', ':searchPattern'), - $qb->expr()->like('s.shortCode', ':searchPattern'), - $qb->expr()->like('s.title', ':searchPattern'), - $qb->expr()->like('d.authority', ':searchPattern'), - ]; - - // Include default domain in search if provided - if ($filtering->searchIncludesDefaultDomain) { - $conditions[] = $qb->expr()->isNull('s.domain'); - } - - // Apply tag conditions, only when not filtering by all provided tags - $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; - if (empty($tags) || $tagsMode === TagsMode::ANY) { - $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); - } - - $qb->leftJoin('s.domain', 'd') - ->andWhere($qb->expr()->orX(...$conditions)) - ->setParameter('searchPattern', '%' . $searchTerm . '%'); - } - - // Filter by tags if provided - if (! empty($tags)) { - $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; - $tagsMode === TagsMode::ANY - ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) - : $this->joinAllTags($qb, $tags); - } - - if ($filtering->excludeMaxVisitsReached) { - $qb->andWhere($qb->expr()->orX( - $qb->expr()->isNull('s.maxVisits'), - $qb->expr()->gt( - 's.maxVisits', - sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class), - ), - )); - } - - if ($filtering->excludePastValidUntil) { - $qb - ->andWhere($qb->expr()->orX( - $qb->expr()->isNull('s.validUntil'), - $qb->expr()->gte('s.validUntil', ':minValidUntil'), - )) - ->setParameter('minValidUntil', Chronos::now()->toDateTimeString()); - } - - $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); - - return $qb; - } - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index 79cd0352..ad5e3a5d 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -10,16 +10,10 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findList(ShortUrlsListFiltering $filtering): array; - - public function countList(ShortUrlsCountFiltering $filtering): int; - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php new file mode 100644 index 00000000..5287f14d --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -0,0 +1,31 @@ +urlShortenerOptions->domain['hostname'] ?? ''; + $paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain)); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); + + return $paginator; + } +} diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php new file mode 100644 index 00000000..ef7b31c2 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -0,0 +1,18 @@ +em->getRepository(ShortUrl::class); - $defaultDomain = $this->urlShortenerOptions->domain['hostname'] ?? ''; - $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey, $defaultDomain)); - $paginator->setMaxPerPage($params->itemsPerPage) - ->setCurrentPage($params->page); - - return $paginator; - } - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index 0ada5fe0..3365374e 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -4,22 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlServiceInterface { - /** - * @return ShortUrl[]|Paginator - */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php new file mode 100644 index 00000000..76c87bd0 --- /dev/null +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -0,0 +1,337 @@ +getEntityManager(); + $this->repo = new ShortUrlListRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->relationResolver = new PersistenceShortUrlRelationResolver($em); + } + + /** @test */ + public function countListReturnsProperNumberOfResults(): void + { + $count = 5; + for ($i = 0; $i < $count; $i++) { + $this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); + } + $this->getEntityManager()->flush(); + + self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); + } + + /** @test */ + public function findListProperlyFiltersResult(): void + { + $foo = ShortUrl::create( + ShortUrlCreation::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), + $this->relationResolver, + ); + $this->getEntityManager()->persist($foo); + + $bar = ShortUrl::withLongUrl('bar'); + $visit = Visit::forValidShortUrl($bar, Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + $bar->setVisits(new ArrayCollection([$visit])); + $this->getEntityManager()->persist($bar); + + $foo2 = ShortUrl::withLongUrl('foo_2'); + $ref = new ReflectionObject($foo2); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($foo2, Chronos::now()->subDays(5)); + $this->getEntityManager()->persist($foo2); + + $this->getEntityManager()->flush(); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']), + ); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); + self::assertSame($foo, $result[0]); + + // Assert searched text also applies to tags + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); + self::assertCount(2, $result); + self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); + self::assertContains($foo, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); + self::assertCount(3, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance())); + self::assertCount(2, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance())); + self::assertCount(2, $result); + + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance()))); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['visits', 'DESC'])), + ); + self::assertCount(3, $result); + self::assertSame($bar, $result[0]); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( + Chronos::now()->subDays(2), + )), + ); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( + Chronos::now()->subDays(2), + )))); + self::assertSame($foo2, $result[0]); + + self::assertCount(2, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( + Chronos::now()->subDays(2), + )), + )); + self::assertEquals(2, $this->repo->countList( + new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), + )); + } + + /** @test */ + public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void + { + $urls = ['a', 'z', 'c', 'b']; + foreach ($urls as $url) { + $this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); + } + + $this->getEntityManager()->flush(); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])), + ); + + self::assertCount(count($urls), $result); + self::assertEquals('a', $result[0]->getLongUrl()); + self::assertEquals('b', $result[1]->getLongUrl()); + self::assertEquals('c', $result[2]->getLongUrl()); + self::assertEquals('z', $result[3]->getLongUrl()); + } + + /** @test */ + public function findListReturnsOnlyThoseWithMatchingTags(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'tags' => ['foo', 'bar'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'tags' => ['foo', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + 'tags' => ['foo'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo4', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo5', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl5); + + $this->getEntityManager()->flush(); + + self::assertCount(5, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']), + )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar'], + TagsMode::ANY, + ))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar'], + TagsMode::ALL, + ))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); + + self::assertCount(4, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), + )); + self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['bar', 'baz'], + TagsMode::ANY, + ))); + self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['bar', 'baz'], + TagsMode::ALL, + ))); + self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); + self::assertEquals(4, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), + )); + self::assertEquals(2, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), + )); + + self::assertCount(5, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']), + )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar', 'baz'], + TagsMode::ANY, + ))); + self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar', 'baz'], + TagsMode::ALL, + ))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); + self::assertEquals(5, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), + )); + self::assertEquals(0, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), + )); + } + + /** @test */ + public function findListReturnsOnlyThoseWithMatchingDomains(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'domain' => null, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'domain' => null, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + 'domain' => 'another.com', + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + + $this->getEntityManager()->flush(); + + $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + searchTerm: $searchTerm, + defaultDomain: 'deFaulT-domain.com', + ); + + self::assertCount(2, $this->repo->findList($buildFiltering('default-dom'))); + self::assertCount(2, $this->repo->findList($buildFiltering('DOM'))); + self::assertCount(1, $this->repo->findList($buildFiltering('another'))); + self::assertCount(3, $this->repo->findList($buildFiltering('foo'))); + self::assertCount(0, $this->repo->findList($buildFiltering('no results'))); + } + + /** @test */ + public function findListReturnsOnlyThoseWithoutExcludedUrls(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'validUntil' => Chronos::now()->addDays(1)->toAtomString(), + 'maxVisits' => 100, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'validUntil' => Chronos::now()->subDays(1)->toAtomString(), + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo4', + 'maxVisits' => 3, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + + $this->getEntityManager()->flush(); + + $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => + new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + excludeMaxVisitsReached: $excludeMaxVisitsReached, + excludePastValidUntil: $excludePastValidUntil, + ); + + self::assertCount(4, $this->repo->findList($filtering(false, false))); + self::assertEquals(4, $this->repo->countList($filtering(false, false))); + self::assertCount(3, $this->repo->findList($filtering(true, false))); + self::assertEquals(3, $this->repo->countList($filtering(true, false))); + self::assertCount(3, $this->repo->findList($filtering(false, true))); + self::assertEquals(3, $this->repo->countList($filtering(false, true))); + self::assertCount(2, $this->repo->findList($filtering(true, true))); + self::assertEquals(2, $this->repo->countList($filtering(true, true))); + } +} diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index dc1d6420..a477eff8 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -5,21 +5,12 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; use Cake\Chronos\Chronos; -use Doctrine\Common\Collections\ArrayCollection; -use ReflectionObject; -use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; @@ -27,8 +18,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; -use function count; - class ShortUrlRepositoryTest extends DatabaseTestCase { private ShortUrlRepository $repo; @@ -86,307 +75,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase )); } - /** @test */ - public function countListReturnsProperNumberOfResults(): void - { - $count = 5; - for ($i = 0; $i < $count; $i++) { - $this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); - } - $this->getEntityManager()->flush(); - - self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); - } - - /** @test */ - public function findListProperlyFiltersResult(): void - { - $foo = ShortUrl::create( - ShortUrlCreation::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), - $this->relationResolver, - ); - $this->getEntityManager()->persist($foo); - - $bar = ShortUrl::withLongUrl('bar'); - $visit = Visit::forValidShortUrl($bar, Visitor::emptyInstance()); - $this->getEntityManager()->persist($visit); - $bar->setVisits(new ArrayCollection([$visit])); - $this->getEntityManager()->persist($bar); - - $foo2 = ShortUrl::withLongUrl('foo_2'); - $ref = new ReflectionObject($foo2); - $dateProp = $ref->getProperty('dateCreated'); - $dateProp->setAccessible(true); - $dateProp->setValue($foo2, Chronos::now()->subDays(5)); - $this->getEntityManager()->persist($foo2); - - $this->getEntityManager()->flush(); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']), - ); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); - self::assertSame($foo, $result[0]); - - // Assert searched text also applies to tags - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); - self::assertCount(2, $result); - self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); - self::assertContains($foo, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); - self::assertCount(3, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance())); - self::assertCount(2, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance())); - self::assertCount(2, $result); - - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance()))); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['visits', 'DESC'])), - ); - self::assertCount(3, $result); - self::assertSame($bar, $result[0]); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )), - ); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )))); - self::assertSame($foo2, $result[0]); - - self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( - Chronos::now()->subDays(2), - )), - )); - self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), - )); - } - - /** @test */ - public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void - { - $urls = ['a', 'z', 'c', 'b']; - foreach ($urls as $url) { - $this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); - } - - $this->getEntityManager()->flush(); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])), - ); - - self::assertCount(count($urls), $result); - self::assertEquals('a', $result[0]->getLongUrl()); - self::assertEquals('b', $result[1]->getLongUrl()); - self::assertEquals('c', $result[2]->getLongUrl()); - self::assertEquals('z', $result[3]->getLongUrl()); - } - - /** @test */ - public function findListReturnsOnlyThoseWithMatchingTags(): void - { - $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo1', - 'tags' => ['foo', 'bar'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl1); - $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo2', - 'tags' => ['foo', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo3', - 'tags' => ['foo'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl3); - $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo4', - 'tags' => ['bar', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl4); - $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo5', - 'tags' => ['bar', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl5); - - $this->getEntityManager()->flush(); - - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']), - )); - self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar'], - TagsMode::ANY, - ))); - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar'], - TagsMode::ALL, - ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); - - self::assertCount(4, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), - )); - self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['bar', 'baz'], - TagsMode::ANY, - ))); - self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['bar', 'baz'], - TagsMode::ALL, - ))); - self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); - self::assertEquals(4, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), - )); - self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), - )); - - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']), - )); - self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ANY, - ))); - self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ALL, - ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); - self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), - )); - self::assertEquals(0, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), - )); - } - - /** @test */ - public function findListReturnsOnlyThoseWithMatchingDomains(): void - { - $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo1', - 'domain' => null, - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl1); - $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo2', - 'domain' => null, - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo3', - 'domain' => 'another.com', - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl3); - - $this->getEntityManager()->flush(); - - $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - searchTerm: $searchTerm, - defaultDomain: 'deFaulT-domain.com', - ); - - self::assertCount(2, $this->repo->findList($buildFiltering('default-dom'))); - self::assertCount(2, $this->repo->findList($buildFiltering('DOM'))); - self::assertCount(1, $this->repo->findList($buildFiltering('another'))); - self::assertCount(3, $this->repo->findList($buildFiltering('foo'))); - self::assertCount(0, $this->repo->findList($buildFiltering('no results'))); - } - - /** @test */ - public function findListReturnsOnlyThoseWithoutExcludedUrls(): void - { - $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo1', - 'validUntil' => Chronos::now()->addDays(1)->toAtomString(), - 'maxVisits' => 100, - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl1); - $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo2', - 'validUntil' => Chronos::now()->subDays(1)->toAtomString(), - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo3', - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl3); - $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo4', - 'maxVisits' => 3, - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl4); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); - - $this->getEntityManager()->flush(); - - $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => - new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - excludeMaxVisitsReached: $excludeMaxVisitsReached, - excludePastValidUntil: $excludePastValidUntil, - ); - - self::assertCount(4, $this->repo->findList($filtering(false, false))); - self::assertEquals(4, $this->repo->countList($filtering(false, false))); - self::assertCount(3, $this->repo->findList($filtering(true, false))); - self::assertEquals(3, $this->repo->countList($filtering(true, false))); - self::assertCount(3, $this->repo->findList($filtering(false, true))); - self::assertEquals(3, $this->repo->countList($filtering(false, true))); - self::assertCount(2, $this->repo->findList($filtering(true, true))); - self::assertEquals(2, $this->repo->countList($filtering(true, true))); - } - /** @test */ public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void { diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index e271448c..684c1528 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -12,16 +12,16 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapterTest extends TestCase { - private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & ShortUrlListRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlListRepositoryInterface::class); } /** diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php new file mode 100644 index 00000000..be8eb852 --- /dev/null +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -0,0 +1,53 @@ +repo = $this->createMock(ShortUrlListRepositoryInterface::class); + $this->service = new ShortUrlListService($this->repo, new UrlShortenerOptions()); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void + { + $list = [ + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ]; + + $this->repo->expects($this->once())->method('findList')->willReturn($list); + $this->repo->expects($this->once())->method('countList')->willReturn(count($list)); + + $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); + + self::assertCount(4, $paginator); + self::assertCount(4, $paginator->getCurrentPageResults()); + } +} diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 7b27f4e4..9cc0d955 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -8,21 +8,16 @@ use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; -use function count; - class ShortUrlServiceTest extends TestCase { use ApiKeyHelpersTrait; @@ -46,34 +41,9 @@ class ShortUrlServiceTest extends TestCase $this->urlResolver, $this->titleResolutionHelper, new SimpleShortUrlRelationResolver(), - new UrlShortenerOptions(), ); } - /** - * @test - * @dataProvider provideAdminApiKeys - */ - public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void - { - $list = [ - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ]; - - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->once())->method('findList')->willReturn($list); - $repo->expects($this->once())->method('countList')->willReturn(count($list)); - $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($repo); - - $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); - - self::assertCount(4, $paginator); - self::assertCount(4, $paginator->getCurrentPageResults()); - } - /** * @test * @dataProvider provideShortUrlEdits diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index ce917b7b..cf394740 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -90,7 +90,10 @@ return [ Visit\Transformer\OrphanVisitDataTransformer::class, ], Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class], - Action\ShortUrl\ListShortUrlsAction::class => [ShortUrl\ShortUrlService::class, ShortUrlDataTransformer::class], + Action\ShortUrl\ListShortUrlsAction::class => [ + ShortUrl\ShortUrlListService::class, + ShortUrlDataTransformer::class, + ], Action\Tag\ListTagsAction::class => [TagService::class], Action\Tag\TagsStatsAction::class => [TagService::class], Action\Tag\DeleteTagsAction::class => [TagService::class], diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 1f915ea1..8ca247a7 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -22,8 +22,8 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct( - private ShortUrlServiceInterface $shortUrlService, - private DataTransformerInterface $transformer, + private readonly ShortUrlListServiceInterface $shortUrlService, + private readonly DataTransformerInterface $transformer, ) { } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 5ccd20ec..4164e78b 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -13,7 +13,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,11 +21,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListShortUrlsActionTest extends TestCase { private ListShortUrlsAction $action; - private MockObject & ShortUrlService $service; + private MockObject & ShortUrlListServiceInterface $service; protected function setUp(): void { - $this->service = $this->createMock(ShortUrlService::class); + $this->service = $this->createMock(ShortUrlListServiceInterface::class); $this->action = new ListShortUrlsAction($this->service, new ShortUrlDataTransformer( new ShortUrlStringifier([