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