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 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/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/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 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": { 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 9202957b..14ea1851 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,9 +4,9 @@ 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\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Input\EndDateOption; +use Shlinkio\Shlink\CLI\Input\StartDateOption; +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 402d5ba4..ba518656 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -4,9 +4,9 @@ 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\Util\ExitCodes; +use Shlinkio\Shlink\CLI\Input\EndDateOption; +use Shlinkio\Shlink\CLI\Input\StartDateOption; +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/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; 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/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); + } +} 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/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/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 new file mode 100644 index 00000000..8ad6713f --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..46e9fde5 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000..61a8af9b --- /dev/null +++ b/module/Core/src/Visit/Repository/VisitDeleterRepositoryInterface.php @@ -0,0 +1,12 @@ +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(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)); + } +} diff --git a/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php new file mode 100644 index 00000000..e1690a5b --- /dev/null +++ b/module/Core/test/ShortUrl/ShortUrlVisitsDeleterTest.php @@ -0,0 +1,55 @@ +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(''); + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + + $this->resolver->expects($this->once())->method('resolveShortUrl')->with($identifier, null)->willReturn( + $shortUrl, + ); + $this->repository->expects($this->once())->method('deleteShortUrlVisits')->with($shortUrl)->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]; + } +} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index cf394740..43625c41 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -32,6 +32,7 @@ return [ Action\ShortUrl\DeleteShortUrlAction::class => 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')); + } +} diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 54d1d45a..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 @@ -320,27 +319,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 { 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()); + } +} 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]; + } +}