From 2573c2bf987f4fdc1811f9eb4402bd584bec6b1f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 11:56:49 +0200 Subject: [PATCH 01/13] Update roadrunner config --- config/roadrunner/.rr.dev.yml | 15 +++------------ config/roadrunner/.rr.yml | 2 +- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/config/roadrunner/.rr.dev.yml b/config/roadrunner/.rr.dev.yml index 9038c07e..a69a805f 100644 --- a/config/roadrunner/.rr.dev.yml +++ b/config/roadrunner/.rr.dev.yml @@ -1,4 +1,4 @@ -version: '2.7' +version: '3.0' rpc: listen: tcp://127.0.0.1:6001 @@ -14,10 +14,12 @@ http: forbid: ['.php', '.htaccess'] pool: num_workers: 1 + debug: true jobs: pool: num_workers: 1 + debug: true timeout: 300 consume: ['shlink'] pipelines: @@ -36,14 +38,3 @@ logs: level: debug metrics: level: debug - -reload: - interval: 1s - patterns: ['.php'] - services: - http: - dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor'] - recursive: true - jobs: - dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor'] - recursive: true diff --git a/config/roadrunner/.rr.yml b/config/roadrunner/.rr.yml index b4074f96..8d1344d7 100644 --- a/config/roadrunner/.rr.yml +++ b/config/roadrunner/.rr.yml @@ -1,4 +1,4 @@ -version: '2.7' +version: '3.0' rpc: listen: tcp://127.0.0.1:6001 From 84a7981dfaf2c0386625c8d2ed0d63f4ae9dc908 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 12:00:08 +0200 Subject: [PATCH 02/13] Create REST action to delete short URL visits --- config/autoload/routes.config.php | 1 + module/Core/config/dependencies.config.php | 9 +++++ module/Core/src/Model/BulkDeleteResult.php | 17 ++++++++++ .../src/ShortUrl/ShortUrlVisitsDeleter.php | 25 ++++++++++++++ .../ShortUrlVisitsDeleterInterface.php | 14 ++++++++ .../Repository/VisitDeleterRepository.php | 18 ++++++++++ .../VisitDeleterRepositoryInterface.php | 13 ++++++++ module/Rest/config/dependencies.config.php | 2 ++ .../ShortUrl/DeleteShortUrlVisitsAction.php | 33 +++++++++++++++++++ 9 files changed, 132 insertions(+) create mode 100644 module/Core/src/Model/BulkDeleteResult.php create mode 100644 module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php create mode 100644 module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php create mode 100644 module/Core/src/Visit/Repository/VisitDeleterRepository.php create mode 100644 module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php create mode 100644 module/Rest/src/Action/ShortUrl/DeleteShortUrlVisitsAction.php diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index c887d5b7..93464519 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -53,6 +53,7 @@ return (static function (): array { ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\ListShortUrlsAction::getRouteDef(), diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 008db777..8e3e9a52 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -38,6 +38,7 @@ return [ ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class, ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, + ShortUrl\ShortUrlVisitsDeleter::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class, @@ -69,6 +70,10 @@ return [ EntityRepositoryFactory::class, Visit\Entity\Visit::class, ], + Visit\Repository\VisitDeleterRepository::class => [ + EntityRepositoryFactory::class, + Visit\Entity\Visit::class, + ], Util\UrlValidator::class => ConfigAbstractFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, @@ -137,6 +142,10 @@ return [ ShortUrl\ShortUrlResolver::class, ], ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], + ShortUrl\ShortUrlVisitsDeleter::class => [ + Visit\Repository\VisitDeleterRepository::class, + ShortUrl\ShortUrlResolver::class, + ], ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], diff --git a/module/Core/src/Model/BulkDeleteResult.php b/module/Core/src/Model/BulkDeleteResult.php new file mode 100644 index 00000000..b3b0e756 --- /dev/null +++ b/module/Core/src/Model/BulkDeleteResult.php @@ -0,0 +1,17 @@ + $this->affectedItems]; + } +} diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php new file mode 100644 index 00000000..097c8875 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -0,0 +1,25 @@ +resolver->resolveShortUrl($identifier, $apiKey); + return new BulkDeleteResult($this->repository->deleteShortUrlVisits($identifier, $apiKey)); + } +} diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php new file mode 100644 index 00000000..dc29ef94 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -0,0 +1,14 @@ + ConfigAbstractFactory::class, Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, + Action\ShortUrl\DeleteShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class, @@ -94,6 +95,7 @@ return [ ShortUrl\ShortUrlListService::class, ShortUrlDataTransformer::class, ], + Action\ShortUrl\DeleteShortUrlVisitsAction::class => [ShortUrl\ShortUrlVisitsDeleter::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/DeleteShortUrlVisitsAction.php b/module/Rest/src/Action/ShortUrl/DeleteShortUrlVisitsAction.php new file mode 100644 index 00000000..c9eaf958 --- /dev/null +++ b/module/Rest/src/Action/ShortUrl/DeleteShortUrlVisitsAction.php @@ -0,0 +1,33 @@ +deleter->deleteShortUrlVisits($identifier, $apiKey); + + return new JsonResponse($result->toArray('deletedVisits')); + } +} From ffc0555c7c01fa281a1999f8cf05771f76eae66f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 12:15:35 +0200 Subject: [PATCH 03/13] Create DeleteShortUrlVisitsActionTest --- .../DeleteShortUrlVisitsActionTest.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 module/Rest/test/Action/ShortUrl/DeleteShortUrlVisitsActionTest.php diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlVisitsActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlVisitsActionTest.php new file mode 100644 index 00000000..8ff727c6 --- /dev/null +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlVisitsActionTest.php @@ -0,0 +1,56 @@ +deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class); + $this->action = new DeleteShortUrlVisitsAction($this->deleter); + } + + #[Test, DataProvider('provideVisitsCounts')] + public function visitsAreDeletedForShortUrl(int $visitsCount): void + { + $apiKey = ApiKey::create(); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey) + ->withAttribute('shortCode', 'foo'); + + $this->deleter->expects($this->once())->method('deleteShortUrlVisits')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + $apiKey, + )->willReturn(new BulkDeleteResult($visitsCount)); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle($request); + $payload = $resp->getPayload(); + + self::assertEquals(['deletedVisits' => $visitsCount], $payload); + } + + public static function provideVisitsCounts(): iterable + { + yield '1' => [1]; + yield '0' => [0]; + yield '300' => [300]; + yield '1234' => [1234]; + } +} From 69ff7de481e8c4878c62df345214068923002b3a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 12:32:54 +0200 Subject: [PATCH 04/13] Create ShortUrlVisitsDeleterTest --- .../src/ShortUrl/ShortUrlVisitsDeleter.php | 4 ++ .../ShortUrlVisitsDeleterInterface.php | 4 ++ .../ShortUrl/ShortUrlVisitsDeleterTest.php | 51 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index 097c8875..5442af7c 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; @@ -17,6 +18,9 @@ class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface ) { } + /** + * @throws ShortUrlNotFoundException + */ public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): BulkDeleteResult { $this->resolver->resolveShortUrl($identifier, $apiKey); diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php index dc29ef94..b0ac0e6a 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; +use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlVisitsDeleterInterface { + /** + * @throws ShortUrlNotFoundException + */ public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): BulkDeleteResult; } diff --git a/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php new file mode 100644 index 00000000..461fc78b --- /dev/null +++ b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php @@ -0,0 +1,51 @@ +repository = $this->createMock(VisitDeleterRepositoryInterface::class); + $this->resolver = $this->createMock(ShortUrlResolverInterface::class); + + $this->deleter = new ShortUrlVisitsDeleter($this->repository, $this->resolver); + } + + #[Test, DataProvider('provideVisitsCounts')] + public function returnsDeletedVisitsFromRepo(int $visitsCount): void + { + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain(''); + + $this->resolver->expects($this->once())->method('resolveShortUrl')->with($identifier, null); + $this->repository->expects($this->once())->method('deleteShortUrlVisits')->with($identifier, null)->willReturn( + $visitsCount, + ); + + $result = $this->deleter->deleteShortUrlVisits($identifier, null); + + self::assertEquals($visitsCount, $result->affectedItems); + } + + public static function provideVisitsCounts(): iterable + { + yield '45' => [45]; + yield '5000' => [5000]; + yield '0' => [0]; + } +} From 531a19dde936873f20b331100e7ec568078152a9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 13:04:17 +0200 Subject: [PATCH 05/13] Refactor short URL visits deletion layers --- module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php | 4 ++-- .../Visit/Repository/VisitDeleterRepository.php | 14 +++++++++----- .../Repository/VisitDeleterRepositoryInterface.php | 5 ++--- .../test/ShortUrl/ShortUrlVisitsDeleterTest.php | 8 ++++++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index 5442af7c..c202c5c2 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -23,7 +23,7 @@ class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface */ public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): BulkDeleteResult { - $this->resolver->resolveShortUrl($identifier, $apiKey); - return new BulkDeleteResult($this->repository->deleteShortUrlVisits($identifier, $apiKey)); + $shortUrl = $this->resolver->resolveShortUrl($identifier, $apiKey); + return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl)); } } diff --git a/module/Core/src/Visit/Repository/VisitDeleterRepository.php b/module/Core/src/Visit/Repository/VisitDeleterRepository.php index 79a91f9d..602ba576 100644 --- a/module/Core/src/Visit/Repository/VisitDeleterRepository.php +++ b/module/Core/src/Visit/Repository/VisitDeleterRepository.php @@ -5,14 +5,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Repository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface { - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): int + public function deleteShortUrlVisits(ShortUrl $shortUrl): int { - // TODO: Implement deleteShortUrlVisits() method. - return 0; + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->delete(Visit::class, 'v') + ->where($qb->expr()->eq('v.shortUrl', ':shortUrl')) + ->setParameter('shortUrl', $shortUrl); + + return $qb->getQuery()->execute(); } } diff --git a/module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php index be2df6a3..61a8af9b 100644 --- a/module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php @@ -4,10 +4,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Repository; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface VisitDeleterRepositoryInterface { - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): int; + public function deleteShortUrlVisits(ShortUrl $shortUrl): int; } diff --git a/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php index 461fc78b..e1690a5b 100644 --- a/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php +++ b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleter; @@ -31,9 +32,12 @@ class ShortUrlVisitsDeleterTest extends TestCase public function returnsDeletedVisitsFromRepo(int $visitsCount): void { $identifier = ShortUrlIdentifier::fromShortCodeAndDomain(''); + $shortUrl = ShortUrl::withLongUrl('https://example.com'); - $this->resolver->expects($this->once())->method('resolveShortUrl')->with($identifier, null); - $this->repository->expects($this->once())->method('deleteShortUrlVisits')->with($identifier, null)->willReturn( + $this->resolver->expects($this->once())->method('resolveShortUrl')->with($identifier, null)->willReturn( + $shortUrl, + ); + $this->repository->expects($this->once())->method('deleteShortUrlVisits')->with($shortUrl)->willReturn( $visitsCount, ); From b8143a5bb472213e5b202b4e747c0acf525cb1ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 13:04:45 +0200 Subject: [PATCH 06/13] Create VisitDeleterRepositoryTest --- .../Repository/VisitDeleterRepositoryTest.php | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php diff --git a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php new file mode 100644 index 00000000..1598fc94 --- /dev/null +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -0,0 +1,63 @@ +getEntityManager(); + $this->repo = new VisitDeleterRepository($em, $em->getClassMetadata(Visit::class)); + } + + #[Test] + public function deletesExpectedVisits(): void + { + $shortUrl1 = ShortUrl::withLongUrl('https://foo.com'); + $this->getEntityManager()->persist($shortUrl1); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); + + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://foo.com', + ShortUrlInputFilter::DOMAIN => 's.test', + ShortUrlInputFilter::CUSTOM_SLUG => 'foo', + ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); + $this->getEntityManager()->persist($shortUrl2); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://foo.com', + ShortUrlInputFilter::CUSTOM_SLUG => 'foo', + ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); + $this->getEntityManager()->persist($shortUrl3); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::emptyInstance())); + + $this->getEntityManager()->flush(); + + self::assertEquals(0, $this->repo->deleteShortUrlVisits(ShortUrl::withLongUrl('https://invalid')->setId('99'))); + self::assertEquals(2, $this->repo->deleteShortUrlVisits($shortUrl1)); + self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl1)); + self::assertEquals(4, $this->repo->deleteShortUrlVisits($shortUrl2)); + self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl2)); + self::assertEquals(1, $this->repo->deleteShortUrlVisits($shortUrl3)); + self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl3)); + } +} From 0365728337f957fbd61b3bb73f8cb3c2acb505d0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 13:35:15 +0200 Subject: [PATCH 07/13] Create DeleteShortUrlVisitsTest --- .../Action/DeleteShortUrlVisitsTest.php | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 module/Rest/test-api/Action/DeleteShortUrlVisitsTest.php diff --git a/module/Rest/test-api/Action/DeleteShortUrlVisitsTest.php b/module/Rest/test-api/Action/DeleteShortUrlVisitsTest.php new file mode 100644 index 00000000..045f2c9a --- /dev/null +++ b/module/Rest/test-api/Action/DeleteShortUrlVisitsTest.php @@ -0,0 +1,86 @@ +getTotalVisits()); + self::assertEquals(3, $this->getOrphanVisits()); + + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123/visits'); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(200, $resp->getStatusCode()); + self::assertEquals(3, $payload['deletedVisits']); + self::assertEquals(4, $this->getTotalVisits()); + self::assertEquals(3, $this->getOrphanVisits()); + } + + private function getTotalVisits(): int + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/non-orphan'); + $payload = $this->getJsonResponsePayload($resp); + + return $payload['visits']['pagination']['totalItems']; + } + + private function getOrphanVisits(): int + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan'); + $payload = $this->getJsonResponsePayload($resp); + + return $payload['visits']['pagination']['totalItems']; + } + + #[Test, DataProvider('provideInvalidShortUrls')] + public function returnsErrorForInvalidShortUrls(string $uri, array $options, string $expectedError): void + { + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/rest/v3' . $uri, $options); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(404, $resp->getStatusCode()); + self::assertEquals($expectedError, $payload['detail']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + public static function provideInvalidShortUrls(): iterable + { + yield 'not exists' => [ + '/short-urls/does-not-exist/visits', + [], + 'No URL found with short code "does-not-exist"', + ]; + yield 'needs domain' => [ + '/short-urls/custom-with-domain/visits', + [], + 'No URL found with short code "custom-with-domain"', + ]; + yield 'invalid domain' => [ + '/short-urls/abc123/visits', + [RequestOptions::QUERY => ['domain' => 'ff.test']], + 'No URL found with short code "abc123" for domain "ff.test"', + ]; + yield 'wrong domain' => [ + '/short-urls/custom-with-domain/visits', + [RequestOptions::QUERY => ['domain' => 'ff.test']], + 'No URL found with short code "custom-with-domain" for domain "ff.test"', + ]; + } + + #[Test] + public function cannotDeleteVisitsForShortUrlWithWrongApiKeyPermissions(): void + { + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123/visits', [], 'domain_api_key'); + self::assertEquals(404, $resp->getStatusCode()); + } +} From 3cf253fd0f5264e4532de27f65e79ce47b37880f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 14 May 2023 18:25:21 +0200 Subject: [PATCH 08/13] Document short URLs visits deletion endpoint --- docs/swagger/parameters/shortCode.json | 9 ++ .../paths/v1_short-urls_{shortCode}.json | 24 +----- .../v1_short-urls_{shortCode}_visits.json | 82 +++++++++++++++++-- docs/swagger/paths/{shortCode}.json | 8 +- docs/swagger/paths/{shortCode}_qr-code.json | 8 +- docs/swagger/paths/{shortCode}_track.json | 8 +- 6 files changed, 90 insertions(+), 49 deletions(-) create mode 100644 docs/swagger/parameters/shortCode.json diff --git a/docs/swagger/parameters/shortCode.json b/docs/swagger/parameters/shortCode.json new file mode 100644 index 00000000..f8eddca2 --- /dev/null +++ b/docs/swagger/parameters/shortCode.json @@ -0,0 +1,9 @@ +{ + "name": "shortCode", + "in": "path", + "description": "The short code for the short URL.", + "required": true, + "schema": { + "type": "string" + } +} diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index e639f362..408d166c 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -11,13 +11,7 @@ "$ref": "../parameters/version.json" }, { - "name": "shortCode", - "in": "path", - "description": "The short code to resolve.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" }, { "$ref": "../parameters/domain.json" @@ -127,13 +121,7 @@ "$ref": "../parameters/version.json" }, { - "name": "shortCode", - "in": "path", - "description": "The short code to edit.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" }, { "$ref": "../parameters/domain.json" @@ -295,13 +283,7 @@ "$ref": "../parameters/version.json" }, { - "name": "shortCode", - "in": "path", - "description": "The short code to edit.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" }, { "$ref": "../parameters/domain.json" diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index e86bb698..2f102711 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -11,13 +11,7 @@ "$ref": "../parameters/version.json" }, { - "name": "shortCode", - "in": "path", - "description": "The short code for the short URL from which we want to get the visits.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" }, { "$ref": "../parameters/domain.json" @@ -172,5 +166,79 @@ } } } + }, + + "delete": { + "operationId": "deleteShortUrlVisits", + "tags": [ + "Visits" + ], + "summary": "Delete visits for short URL", + "description": "Delete all existing visits on the short URL behind provided short code.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "Deleted visits", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deletedVisits": { + "description": "Amount of affected visits", + "type": "number" + } + } + }, + "example": { + "deletedVisits": 536 + } + } + } + }, + "404": { + "description": "The short code does not belong to any short URL.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + }, + "examples": { + "Short URL not found with API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Short URL not found previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } } } diff --git a/docs/swagger/paths/{shortCode}.json b/docs/swagger/paths/{shortCode}.json index bbebacbd..464063da 100644 --- a/docs/swagger/paths/{shortCode}.json +++ b/docs/swagger/paths/{shortCode}.json @@ -8,13 +8,7 @@ "description": "Represents a short URL. Tracks the visit and redirects tio the corresponding long URL", "parameters": [ { - "name": "shortCode", - "in": "path", - "description": "The short code to resolve.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" } ], "responses": { diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index dd5c8b8a..ca66a079 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -8,13 +8,7 @@ "description": "Generates a QR code image pointing to a short URL.
Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.", "parameters": [ { - "name": "shortCode", - "in": "path", - "description": "The short code to resolve.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" }, { "name": "size", diff --git a/docs/swagger/paths/{shortCode}_track.json b/docs/swagger/paths/{shortCode}_track.json index 50f6bc5e..96e32411 100644 --- a/docs/swagger/paths/{shortCode}_track.json +++ b/docs/swagger/paths/{shortCode}_track.json @@ -8,13 +8,7 @@ "description": "Generates a 1px transparent image which can be used to track emails with a short URL", "parameters": [ { - "name": "shortCode", - "in": "path", - "description": "The short code to resolve.", - "required": true, - "schema": { - "type": "string" - } + "$ref": "../parameters/shortCode.json" } ], "responses": { From 6bb8c1b2f54515adcdefc4e32057e8ba1868da5e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 May 2023 09:02:23 +0200 Subject: [PATCH 09/13] Rename CLI Option namespace to Input --- module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php | 4 ++-- module/CLI/src/Command/Visit/AbstractVisitsListCommand.php | 4 ++-- module/CLI/src/{Option => Input}/DateOption.php | 2 +- module/CLI/src/{Option => Input}/EndDateOption.php | 2 +- module/CLI/src/{Option => Input}/StartDateOption.php | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename module/CLI/src/{Option => Input}/DateOption.php (97%) rename module/CLI/src/{Option => Input}/EndDateOption.php (95%) rename module/CLI/src/{Option => Input}/StartDateOption.php (95%) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 9202957b..f31de184 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Option\EndDateOption; -use Shlinkio\Shlink\CLI\Option\StartDateOption; +use Shlinkio\Shlink\CLI\Input\EndDateOption; +use Shlinkio\Shlink\CLI\Input\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 402d5ba4..2cd9c0c8 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Option\EndDateOption; -use Shlinkio\Shlink\CLI\Option\StartDateOption; +use Shlinkio\Shlink\CLI\Input\EndDateOption; +use Shlinkio\Shlink\CLI\Input\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; diff --git a/module/CLI/src/Option/DateOption.php b/module/CLI/src/Input/DateOption.php similarity index 97% rename from module/CLI/src/Option/DateOption.php rename to module/CLI/src/Input/DateOption.php index a863696f..41407d23 100644 --- a/module/CLI/src/Option/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\CLI\Option; +namespace Shlinkio\Shlink\CLI\Input; use Cake\Chronos\Chronos; use Symfony\Component\Console\Command\Command; diff --git a/module/CLI/src/Option/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php similarity index 95% rename from module/CLI/src/Option/EndDateOption.php rename to module/CLI/src/Input/EndDateOption.php index 72421981..000a135e 100644 --- a/module/CLI/src/Option/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\CLI\Option; +namespace Shlinkio\Shlink\CLI\Input; use Cake\Chronos\Chronos; use Symfony\Component\Console\Command\Command; diff --git a/module/CLI/src/Option/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php similarity index 95% rename from module/CLI/src/Option/StartDateOption.php rename to module/CLI/src/Input/StartDateOption.php index 2da5aaee..0954e82f 100644 --- a/module/CLI/src/Option/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\CLI\Option; +namespace Shlinkio\Shlink\CLI\Input; use Cake\Chronos\Chronos; use Symfony\Component\Console\Command\Command; From 02a8ef7dd9b8718f76bdc7ba3405d4bdf6a1bc1b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 15 May 2023 09:43:05 +0200 Subject: [PATCH 10/13] Create DeleteShortUrlVisitsCommand --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 2 + .../CLI/src/Command/Api/DisableKeyCommand.php | 6 +- .../src/Command/Api/GenerateKeyCommand.php | 4 +- .../CLI/src/Command/Api/ListKeysCommand.php | 4 +- .../src/Command/Db/CreateDatabaseCommand.php | 6 +- .../src/Command/Db/MigrateDatabaseCommand.php | 4 +- .../Command/Domain/DomainRedirectsCommand.php | 4 +- .../src/Command/Domain/ListDomainsCommand.php | 4 +- .../ShortUrl/CreateShortUrlCommand.php | 8 +-- .../ShortUrl/DeleteShortUrlCommand.php | 8 +-- .../ShortUrl/DeleteShortUrlVisitsCommand.php | 72 +++++++++++++++++++ .../Command/ShortUrl/ListShortUrlsCommand.php | 4 +- .../Command/ShortUrl/ResolveUrlCommand.php | 6 +- .../CLI/src/Command/Tag/DeleteTagsCommand.php | 6 +- .../CLI/src/Command/Tag/ListTagsCommand.php | 4 +- .../CLI/src/Command/Tag/RenameTagCommand.php | 6 +- .../Command/Util/AbstractLockedCommand.php | 4 +- .../Visit/AbstractVisitsListCommand.php | 4 +- .../Visit/DownloadGeoLiteDbCommand.php | 6 +- .../src/Command/Visit/LocateVisitsCommand.php | 8 +-- .../src/Util/{ExitCodes.php => ExitCode.php} | 2 +- .../test-cli/Command/CreateShortUrlTest.php | 4 +- .../test-cli/Command/GenerateApiKeyTest.php | 4 +- .../CLI/test-cli/Command/ListApiKeysTest.php | 4 +- .../Command/Domain/ListDomainsCommandTest.php | 4 +- .../ShortUrl/CreateShortUrlCommandTest.php | 12 ++-- .../Visit/DownloadGeoLiteDbCommandTest.php | 8 +-- .../Command/Visit/LocateVisitsCommandTest.php | 16 ++--- .../src/ShortUrl/Model/ShortUrlIdentifier.php | 11 +++ .../src/ShortUrl/ShortUrlVisitsDeleter.php | 2 +- .../ShortUrlVisitsDeleterInterface.php | 2 +- .../Repository/VisitDeleterRepositoryTest.php | 1 - .../test-api/Action/CreateShortUrlTest.php | 21 ------ 34 files changed, 163 insertions(+), 99 deletions(-) create mode 100644 module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php rename module/CLI/src/Util/{ExitCodes.php => ExitCode.php} (89%) diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 7629d855..012c6800 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -13,6 +13,7 @@ return [ Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class, Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, + Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index e5176f42..384df91d 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -42,6 +42,7 @@ return [ Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, @@ -88,6 +89,7 @@ return [ ], Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class], + Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class], Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 7296632a..4844121e 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; @@ -39,10 +39,10 @@ class DisableKeyCommand extends Command try { $this->apiKeyService->disable($apiKey); $io->success(sprintf('API key "%s" properly disabled', $apiKey)); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (InvalidArgumentException $e) { $io->error($e->getMessage()); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } } } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index c89c4fbf..c2d6cf10 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -109,6 +109,6 @@ class GenerateKeyCommand extends Command ); } - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index c7e31819..87b239b7 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Api; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -77,7 +77,7 @@ class ListKeysCommand extends Command 'Roles', ]), $rows); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function determineMessagePattern(ApiKey $apiKey): string diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 95b08da2..f6df9b04 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -8,7 +8,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -57,7 +57,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand if ($this->schemaExists()) { $io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } // Create database @@ -65,7 +65,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand $this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]); $io->success('Database properly created!'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function checkDbExists(): void diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php index 379e57e0..a912cf24 100644 --- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -31,6 +31,6 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand $this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]); $io->success('Database properly migrated!'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } } diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index c546fd5b..4a3f8062 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Domain; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; @@ -109,6 +109,6 @@ class DomainRedirectsCommand extends Command $io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority)); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } } diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 8f2ee22c..11a0f5b9 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Domain; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; @@ -59,7 +59,7 @@ class ListDomainsCommand extends Command }), ); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index a998a677..f55f247d 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -141,7 +141,7 @@ class CreateShortUrlCommand extends Command $longUrl = $input->getArgument('longUrl'); if (empty($longUrl)) { $io->error('A URL was not provided!'); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } $explodeWithComma = curry(explode(...))(','); @@ -176,10 +176,10 @@ class CreateShortUrlCommand extends Command sprintf('Processed long URL: %s', $longUrl), sprintf('Generated short URL: %s', $this->stringifier->stringify($result->shortUrl)), ]); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (InvalidUrlException | NonUniqueSlugException $e) { $io->error($e->getMessage()); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } } diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 1db5b1f6..11cfa270 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; @@ -55,10 +55,10 @@ class DeleteShortUrlCommand extends Command try { $this->runDelete($io, $identifier, $ignoreThreshold); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (Exception\ShortUrlNotFoundException $e) { $io->error($e->getMessage()); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } catch (Exception\DeleteShortUrlException $e) { return $this->retry($io, $identifier, $e->getMessage()); } @@ -75,7 +75,7 @@ class DeleteShortUrlCommand extends Command $io->warning('Short URL was not deleted.'); } - return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING; + return $forceDelete ? ExitCode::EXIT_SUCCESS : ExitCode::EXIT_WARNING; } private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php new file mode 100644 index 00000000..fa7a8ee6 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php @@ -0,0 +1,72 @@ +setName(self::NAME) + ->setDescription('Deletes visits from a short URL') + ->addArgument( + 'shortCode', + InputArgument::REQUIRED, + 'The short code for the short URL which visits will be deleted', + ) + ->addOption( + 'domain', + 'd', + InputOption::VALUE_REQUIRED, + 'The domain if the short code does not belong to the default one', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): ?int + { + $identifier = ShortUrlIdentifier::fromCli($input); + $io = new SymfonyStyle($input, $output); + if (! $this->confirm($io)) { + $io->info('Operation aborted'); + return ExitCode::EXIT_SUCCESS; + } + + try { + $result = $this->deleter->deleteShortUrlVisits($identifier); + $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); + + return ExitCode::EXIT_SUCCESS; + } catch (ShortUrlNotFoundException) { + $io->warning(sprintf('Short URL not found for "%s"', $identifier->__toString())); + return ExitCode::EXIT_WARNING; + } + } + + private function confirm(SymfonyStyle $io): bool + { + $io->warning('You are about to delete all visits for a short URL. This operation cannot be undone.'); + return $io->confirm('Continue deleting visits?', false); + } +} diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index f31de184..14ea1851 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Input\EndDateOption; use Shlinkio\Shlink\CLI\Input\StartDateOption; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; @@ -173,7 +173,7 @@ class ListShortUrlsCommand extends Command $io->newLine(); $io->success('Short URLs properly listed'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function renderPage( diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index 8d54edd2..aec0a843 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; @@ -56,10 +56,10 @@ class ResolveUrlCommand extends Command try { $url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input)); $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (ShortUrlNotFoundException $e) { $io->error($e->getMessage()); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } } } diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 5a4f81ac..151c5892 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -41,11 +41,11 @@ class DeleteTagsCommand extends Command if (empty($tagNames)) { $io->warning('You have to provide at least one tag name'); - return ExitCodes::EXIT_WARNING; + return ExitCode::EXIT_WARNING; } $this->tagService->deleteTags($tagNames); $io->success('Tags properly deleted'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } } diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 02116d79..41ca9b60 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; @@ -34,7 +34,7 @@ class ListTagsCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): ?int { ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function getTagsRows(): array diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 85377a18..1da3b983 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Tag; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; @@ -42,10 +42,10 @@ class RenameTagCommand extends Command try { $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (TagNotFoundException | TagConflictException $e) { $io->error($e->getMessage()); - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } } } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index d1e45fd8..ae930496 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Util; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -28,7 +28,7 @@ abstract class AbstractLockedCommand extends Command $output->writeln( sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), ); - return ExitCodes::EXIT_WARNING; + return ExitCode::EXIT_WARNING; } try { diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 2cd9c0c8..ba518656 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Input\EndDateOption; use Shlinkio\Shlink\CLI\Input\StartDateOption; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; @@ -43,7 +43,7 @@ abstract class AbstractVisitsListCommand extends Command ShlinkTable::default($output)->render($headers, $rows); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } private function resolveRowsAndHeaders(Paginator $paginator): array diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index c4384d33..23600530 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; @@ -56,7 +56,7 @@ class DownloadGeoLiteDbCommand extends Command $io->success('GeoLite2 db file properly downloaded.'); } - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (GeolocationDbUpdateFailedException $e) { $olderDbExists = $e->olderDbExists(); @@ -72,7 +72,7 @@ class DownloadGeoLiteDbCommand extends Command $this->getApplication()?->renderThrowable($e, $io); } - return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE; + return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE; } } } diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index d83c91e0..09e53556 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -116,14 +116,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } $this->io->success('Finished locating visits'); - return ExitCodes::EXIT_SUCCESS; + return ExitCode::EXIT_SUCCESS; } catch (Throwable $e) { $this->io->error($e->getMessage()); if ($this->io->isVerbose()) { $this->getApplication()?->renderThrowable($e, $this->io); } - return ExitCodes::EXIT_FAILURE; + return ExitCode::EXIT_FAILURE; } } @@ -171,7 +171,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME); $exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io); - if ($exitCode === ExitCodes::EXIT_FAILURE) { + if ($exitCode === ExitCode::EXIT_FAILURE) { throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); } } diff --git a/module/CLI/src/Util/ExitCodes.php b/module/CLI/src/Util/ExitCode.php similarity index 89% rename from module/CLI/src/Util/ExitCodes.php rename to module/CLI/src/Util/ExitCode.php index d915796a..128b9f52 100644 --- a/module/CLI/src/Util/ExitCodes.php +++ b/module/CLI/src/Util/ExitCode.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Util; -final class ExitCodes +final class ExitCode { public const EXIT_SUCCESS = 0; public const EXIT_FAILURE = -1; diff --git a/module/CLI/test-cli/Command/CreateShortUrlTest.php b/module/CLI/test-cli/Command/CreateShortUrlTest.php index d4d8a583..c2e96611 100644 --- a/module/CLI/test-cli/Command/CreateShortUrlTest.php +++ b/module/CLI/test-cli/Command/CreateShortUrlTest.php @@ -7,7 +7,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; class CreateShortUrlTest extends CliTestCase @@ -22,7 +22,7 @@ class CreateShortUrlTest extends CliTestCase [CreateShortUrlCommand::NAME, 'https://example.com', '--domain', $defaultDomain, '--custom-slug', $slug], ); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output); [$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]); diff --git a/module/CLI/test-cli/Command/GenerateApiKeyTest.php b/module/CLI/test-cli/Command/GenerateApiKeyTest.php index c98dc237..7d90c336 100644 --- a/module/CLI/test-cli/Command/GenerateApiKeyTest.php +++ b/module/CLI/test-cli/Command/GenerateApiKeyTest.php @@ -6,7 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; class GenerateApiKeyTest extends CliTestCase @@ -17,6 +17,6 @@ class GenerateApiKeyTest extends CliTestCase [$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]); self::assertStringContainsString('[OK] Generated API key', $output); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } } diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index 80a1134d..f8781d54 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -8,7 +8,7 @@ use Cake\Chronos\Chronos; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; class ListApiKeysTest extends CliTestCase @@ -19,7 +19,7 @@ class ListApiKeysTest extends CliTestCase [$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]); self::assertEquals($expectedOutput, $output); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } public static function provideFlags(): iterable diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index ad31d86d..05cc95eb 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; @@ -53,7 +53,7 @@ class ListDomainsCommandTest extends TestCase $this->commandTester->execute($input); self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode()); } public static function provideInputsAndOutputs(): iterable diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 60482138..46063485 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -65,7 +65,7 @@ class CreateShortUrlCommandTest extends TestCase ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertStringContainsString('stringified_short_url', $output); self::assertStringNotContainsString('but the real-time updates cannot', $output); } @@ -82,7 +82,7 @@ class CreateShortUrlCommandTest extends TestCase $this->commandTester->execute(['longUrl' => $url]); $output = $this->commandTester->getDisplay(); - self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode()); self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); } @@ -97,7 +97,7 @@ class CreateShortUrlCommandTest extends TestCase $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); $output = $this->commandTester->getDisplay(); - self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode()); self::assertStringContainsString('Provided slug "my-slug" is already in use', $output); } @@ -121,7 +121,7 @@ class CreateShortUrlCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertStringContainsString('stringified_short_url', $output); } @@ -139,7 +139,7 @@ class CreateShortUrlCommandTest extends TestCase $input['longUrl'] = 'http://domain.com/foo/bar'; $this->commandTester->execute($input); - self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode()); } public static function provideDomains(): iterable diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 7f2cb3ac..7e904caa 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; @@ -65,12 +65,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase yield 'existing db' => [ true, '[WARNING] GeoLite2 db file update failed. Visits will continue to be located', - ExitCodes::EXIT_WARNING, + ExitCode::EXIT_WARNING, ]; yield 'not existing db' => [ false, '[ERROR] GeoLite2 db file download failed. It will not be possible to locate', - ExitCodes::EXIT_FAILURE, + ExitCode::EXIT_FAILURE, ]; } @@ -86,7 +86,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase $exitCode = $this->commandTester->getStatusCode(); self::assertStringContainsString($expectedMessage, $output); - self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode); + self::assertSame(ExitCode::EXIT_SUCCESS, $exitCode); } public static function provideSuccessParams(): iterable diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index aa775a24..6ff8c242 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; -use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -85,7 +85,7 @@ class LocateVisitsCommandTest extends TestCase $this->visitToLocation->expects( $this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls), )->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance()); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->commandTester->setInputs(['y']); $this->commandTester->execute($args); @@ -118,7 +118,7 @@ class LocateVisitsCommandTest extends TestCase ->withAnyParameters() ->willReturnCallback($this->invokeHelperMethods($visit, $location)); $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); @@ -147,7 +147,7 @@ class LocateVisitsCommandTest extends TestCase $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException( IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')), ); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); @@ -171,7 +171,7 @@ class LocateVisitsCommandTest extends TestCase $this->visitService->expects($this->never())->method('locateUnlocatedVisits'); $this->visitToLocation->expects($this->never())->method('resolveVisitLocation'); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); @@ -186,7 +186,7 @@ class LocateVisitsCommandTest extends TestCase public function showsProperMessageWhenGeoLiteUpdateFails(): void { $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_FAILURE); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_FAILURE); $this->visitService->expects($this->never())->method('locateUnlocatedVisits'); $this->commandTester->execute([]); @@ -199,7 +199,7 @@ class LocateVisitsCommandTest extends TestCase public function providingAllFlagOnItsOwnDisplaysNotice(): void { $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->commandTester->execute(['--all' => true]); $output = $this->commandTester->getDisplay(); @@ -210,7 +210,7 @@ class LocateVisitsCommandTest extends TestCase #[Test, DataProvider('provideAbortInputs')] public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void { - $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCode::EXIT_SUCCESS); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Execution aborted'); diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index bb3b4af6..78becbed 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -8,6 +8,8 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Symfony\Component\Console\Input\InputInterface; +use function sprintf; + final class ShortUrlIdentifier { private function __construct(public readonly string $shortCode, public readonly ?string $domain = null) @@ -54,4 +56,13 @@ final class ShortUrlIdentifier { return new self($shortCode, $domain); } + + public function __toString(): string + { + if ($this->domain === null) { + return $this->shortCode; + } + + return sprintf('%s/%s', $this->domain, $this->shortCode); + } } diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index c202c5c2..8ad6713f 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -21,7 +21,7 @@ class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): BulkDeleteResult + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult { $shortUrl = $this->resolver->resolveShortUrl($identifier, $apiKey); return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl)); diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php index b0ac0e6a..46e9fde5 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -14,5 +14,5 @@ interface ShortUrlVisitsDeleterInterface /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey): BulkDeleteResult; + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult; } diff --git a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php index 1598fc94..53b1585f 100644 --- a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -52,7 +52,6 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals(0, $this->repo->deleteShortUrlVisits(ShortUrl::withLongUrl('https://invalid')->setId('99'))); self::assertEquals(2, $this->repo->deleteShortUrlVisits($shortUrl1)); self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl1)); self::assertEquals(4, $this->repo->deleteShortUrlVisits($shortUrl2)); diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 54d1d45a..dfdd170e 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -320,27 +320,6 @@ class CreateShortUrlTest extends ApiTestCase yield 'example domain' => ['example.com']; } - #[Test, DataProvider('provideTwitterUrls')] - public function urlsWithBotProtectionCanBeShortenedWithUrlValidationEnabled(string $longUrl): void - { - // Requests to Twitter are randomly failing from GitHub actions. Let's skip this test there. - // This is a deprecated and low-used feature anyway. - if (env('CI', false)) { - $this->markTestSkipped(); - } - - [$statusCode] = $this->createShortUrl(['longUrl' => $longUrl, 'validateUrl' => true]); - self::assertEquals(self::STATUS_OK, $statusCode); - } - - public static function provideTwitterUrls(): iterable - { - yield ['https://twitter.com/shlinkio']; - yield ['https://mobile.twitter.com/shlinkio']; - yield ['https://twitter.com/shlinkio/status/1360637738421268481']; - yield ['https://mobile.twitter.com/shlinkio/status/1360637738421268481']; - } - #[Test] public function canCreateShortUrlsWithEmojis(): void { From c7043af853c9dacc633320b3c7d96a8f24b1275e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 16 May 2023 09:26:29 +0200 Subject: [PATCH 11/13] Create DeleteShortUrlVisitsCommandTest --- .../DeleteShortUrlVisitsCommandTest.php | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php new file mode 100644 index 00000000..88c3657a --- /dev/null +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php @@ -0,0 +1,85 @@ +deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class); + $this->commandTester = $this->testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter)); + } + + #[Test, DataProvider('provideCancellingInputs')] + public function executionIsAbortedIfManuallyCancelled(array $input): void + { + $this->deleter->expects($this->never())->method('deleteShortUrlVisits'); + $this->commandTester->setInputs($input); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringContainsString('Operation aborted', $output); + } + + public static function provideCancellingInputs(): iterable + { + yield 'default input' => [[]]; + yield 'no' => [['no']]; + yield 'n' => [['n']]; + } + + #[Test, DataProvider('provideErrorArgs')] + public function warningIsPrintedInCaseOfNotFoundShortUrl(array $args, string $expectedError): void + { + $this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willThrowException( + new ShortUrlNotFoundException(), + ); + $this->commandTester->setInputs(['yes']); + + $exitCode = $this->commandTester->execute($args); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + self::assertStringContainsString($expectedError, $output); + } + + public static function provideErrorArgs(): iterable + { + yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"']; + yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"']; + } + + #[Test] + public function successMessageIsPrintedForValidShortUrls(): void + { + $this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5)); + $this->commandTester->setInputs(['yes']); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringContainsString('Successfully deleted 5 visits', $output); + } +} From 765199727ec6712069f51c474a8bf924a0be9516 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 16 May 2023 09:29:22 +0200 Subject: [PATCH 12/13] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9409d6..8439c1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#1148](https://github.com/shlinkio/shlink/issues/1148) Add support to delete short URL visits. + + This can be done via `DELETE /short-urls/{shortCode}/visits` REST endpoint or via `short-url:delete-visits` console command. + + The CLI command includes a warning and requires the user to confirm before proceeding. + * [#1656](https://github.com/shlinkio/shlink/issues/1656) Add support for openswoole 22 ### Changed From 39095a309818cb29569ae736102922dd6dfe6bda Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 May 2023 08:57:36 +0200 Subject: [PATCH 13/13] Fix coding styles --- config/autoload/dependencies.global.php | 3 ++- module/Rest/test-api/Action/CreateShortUrlTest.php | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index 657caffb..a0014ef6 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -4,6 +4,7 @@ declare(strict_types=1); use GuzzleHttp\Client; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; +use Mezzio\Application; use Mezzio\Container; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ServerRequestFactoryInterface; @@ -20,7 +21,7 @@ return [ ], 'delegators' => [ - Mezzio\Application::class => [ + Application::class => [ Container\ApplicationConfigInjectionDelegator::class, ], ], diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index dfdd170e..5b22e79a 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -12,7 +12,6 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function Functional\map; use function range; -use function Shlinkio\Shlink\Config\env; use function sprintf; class CreateShortUrlTest extends ApiTestCase