mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
2
.github/actions/ci-setup/action.yml
vendored
2
.github/actions/ci-setup/action.yml
vendored
@@ -43,5 +43,5 @@ runs:
|
||||
coverage: xdebug
|
||||
- name: Install dependencies
|
||||
if: ${{ inputs.install-deps == 'yes' }}
|
||||
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.5' && '--ignore-platform-req=php' || '' }}
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
shell: bash
|
||||
|
||||
1
.github/workflows/ci-db-tests.yml
vendored
1
.github/workflows/ci-db-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ matrix.php-version == '8.5' }}
|
||||
env:
|
||||
LC_ALL: C
|
||||
steps:
|
||||
|
||||
1
.github/workflows/ci-tests.yml
vendored
1
.github/workflows/ci-tests.yml
vendored
@@ -14,7 +14,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
continue-on-error: ${{ matrix.php-version == '8.5' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
|
||||
steps:
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4']
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: './.github/actions/ci-setup'
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.6.0] - 2025-11-01
|
||||
### Added
|
||||
* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags.
|
||||
|
||||
Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
|
||||
|
||||
Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
|
||||
|
||||
* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them.
|
||||
|
||||
Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here.
|
||||
|
||||
Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option.
|
||||
|
||||
* [#2330](https://github.com/shlinkio/shlink/issues/2330) Add support to serve Shlink with FrankenPHP, by providing a worker script in `bin/frankenphp-worker.php`.
|
||||
|
||||
* [#2449](https://github.com/shlinkio/shlink/issues/2449) Add support to provide redis credentials separately when using redis sentinels, where provided servers are the sentinels and not the redis instances.
|
||||
|
||||
For this, Shlink supports two new env ras / config options, as `REDIS_SERVERS_USER` and `REDIS_SERVERS_PASSWORD`.
|
||||
|
||||
* [#2498](https://github.com/shlinkio/shlink/issues/2498) Allow orphan visits, non-orphan visits and tag visits lists to be filtered by domain.
|
||||
|
||||
This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands.
|
||||
|
||||
* [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5
|
||||
|
||||
### Changed
|
||||
* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [4.5.3] - 2025-10-10
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
||||
@@ -99,6 +99,12 @@ Both the API and CLI allow you to do mostly the same operations, except for API
|
||||
|
||||
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
|
||||
|
||||
## Powered by
|
||||
|
||||
Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.6",
|
||||
"cakephp/chronos": "^3.1",
|
||||
"doctrine/dbal": "^4.2",
|
||||
"doctrine/migrations": "^3.8",
|
||||
"doctrine/orm": "^3.3",
|
||||
"doctrine/dbal": "^4.3",
|
||||
"doctrine/migrations": "^3.9",
|
||||
"doctrine/orm": "^3.5",
|
||||
"donatj/phpuseragentparser": "^1.10",
|
||||
"endroid/qr-code": "^6.0.5",
|
||||
"friendsofphp/proxy-manager-lts": "^1.0",
|
||||
@@ -43,11 +43,11 @@
|
||||
"pagerfanta/core": "^3.8",
|
||||
"ramsey/uuid": "^4.7",
|
||||
"shlinkio/doctrine-specification": "^2.2",
|
||||
"shlinkio/shlink-common": "^7.1",
|
||||
"shlinkio/shlink-common": "^7.2",
|
||||
"shlinkio/shlink-config": "^4.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^4.3",
|
||||
"shlinkio/shlink-importer": "^5.6",
|
||||
"shlinkio/shlink-installer": "^9.6",
|
||||
"shlinkio/shlink-installer": "^9.7",
|
||||
"shlinkio/shlink-ip-geolocation": "^4.4",
|
||||
"shlinkio/shlink-json": "^1.2",
|
||||
"spiral/roadrunner": "^2025.1",
|
||||
|
||||
@@ -11,6 +11,8 @@ return (static function (): array {
|
||||
'redis' => [
|
||||
'servers' => $redisServers,
|
||||
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
|
||||
'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
|
||||
'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ return [
|
||||
Option\Cache\CacheNamespaceConfigOption::class,
|
||||
Option\Redis\RedisServersConfigOption::class,
|
||||
Option\Redis\RedisSentinelServiceConfigOption::class,
|
||||
Option\Redis\RedisServersUserConfigOption::class,
|
||||
Option\Redis\RedisServersPasswordConfigOption::class,
|
||||
Option\Redis\RedisPubSubConfigOption::class,
|
||||
Option\UrlShortener\ShortCodeLengthOption::class,
|
||||
Option\Mercure\EnableMercureConfigOption::class,
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"in": "query",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
|
||||
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -40,7 +40,7 @@
|
||||
{
|
||||
"name": "tags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
@@ -52,7 +52,29 @@
|
||||
{
|
||||
"name": "tagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
|
||||
"description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".<br />It's ignored if `tags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["any", "all"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTags[]",
|
||||
"in": "query",
|
||||
"description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeTagsMode",
|
||||
"in": "query",
|
||||
"description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".<br />It's ignored if `excludeTags` is not provided.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
@@ -134,6 +156,15 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "apiKeyName",
|
||||
"in": "query",
|
||||
"description": "Only get short URLs created with this API key.<br />This value is **ignored** if the request is performed with a non-admin API key that does not match this name.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"type": "string",
|
||||
"enum": ["true"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"type": "string",
|
||||
"enum": ["invalid_short_url", "base_url", "regular_404"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json",
|
||||
"description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -26,6 +26,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
|
||||
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
|
||||
Command\Api\DeleteKeyCommand::NAME => Command\Api\DeleteKeyCommand::class,
|
||||
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
|
||||
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
|
||||
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,
|
||||
|
||||
@@ -52,6 +52,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\DeleteKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
|
||||
@@ -108,6 +109,7 @@ return [
|
||||
|
||||
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
|
||||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\DeleteKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
|
||||
|
||||
94
module/CLI/src/Command/Api/DeleteKeyCommand.php
Normal file
94
module/CLI/src/Command/Api/DeleteKeyCommand.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: DeleteKeyCommand::NAME,
|
||||
description: 'Deletes an API key by name',
|
||||
help: <<<HELP
|
||||
The <info>%command.name%</info> command allows you to delete an existing API key via its name.
|
||||
|
||||
If no arguments are provided, you will be prompted to select one of the existing API keys.
|
||||
|
||||
<info>%command.full_name%</info>
|
||||
|
||||
You can optionally pass the API key name to be disabled:
|
||||
|
||||
<info>%command.full_name% the_key_name</info>
|
||||
|
||||
HELP,
|
||||
)]
|
||||
class DeleteKeyCommand extends Command
|
||||
{
|
||||
public const string NAME = 'api-key:delete';
|
||||
|
||||
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$apiKeyName = $input->getArgument('name');
|
||||
|
||||
if ($apiKeyName === null) {
|
||||
$apiKeys = $this->apiKeyService->listKeys();
|
||||
$name = (new SymfonyStyle($input, $output))->choice(
|
||||
'What API key do you want to delete?',
|
||||
map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
|
||||
);
|
||||
|
||||
$input->setArgument('name', $name);
|
||||
}
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Argument(description: 'The API key to delete.')]
|
||||
string|null $name = null,
|
||||
): int {
|
||||
if ($name === null) {
|
||||
$io->warning('An API key name was not provided.');
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
if (! $this->shouldProceed($io, $input)) {
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->apiKeyService->deleteByName($name);
|
||||
$io->success(sprintf('API key "%s" properly deleted', $name));
|
||||
return Command::SUCCESS;
|
||||
} catch (ApiKeyNotFoundException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldProceed(SymfonyStyle $io, InputInterface $input): bool
|
||||
{
|
||||
if (! $input->isInteractive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$io->warning('You are about to delete an API key. This action cannot be undone.');
|
||||
return $io->confirm('Are you sure you want to delete the API key?');
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: InitialApiKeyCommand::NAME,
|
||||
description: 'Tries to create initial API key',
|
||||
)]
|
||||
class InitialApiKeyCommand extends Command
|
||||
{
|
||||
public const string NAME = 'api-key:initial';
|
||||
@@ -19,22 +23,14 @@ class InitialApiKeyCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setHidden()
|
||||
->setName(self::NAME)
|
||||
->setDescription('Tries to create initial API key')
|
||||
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
|
||||
}
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The initial API to create')] string $apiKey,
|
||||
): int {
|
||||
$result = $this->apiKeyService->createInitial($apiKey);
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$key = $input->getArgument('apiKey');
|
||||
$result = $this->apiKeyService->createInitial($key);
|
||||
|
||||
if ($result === null && $output->isVerbose()) {
|
||||
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
|
||||
if ($result === null && $io->isVerbose()) {
|
||||
$io->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\CLI\Command\Config;
|
||||
|
||||
use Closure;
|
||||
use Shlinkio\Shlink\Core\Config\EnvVars;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@@ -18,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: ReadEnvVarCommand::NAME,
|
||||
description: 'Display current value for an env var',
|
||||
hidden: true,
|
||||
)]
|
||||
class ReadEnvVarCommand extends Command
|
||||
{
|
||||
public const string NAME = 'env-var:read';
|
||||
@@ -31,19 +37,10 @@ class ReadEnvVarCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setHidden()
|
||||
->setDescription('Display current value for an env var')
|
||||
->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$envVar = $input->getArgument('env-var');
|
||||
$validEnvVars = enumValues(EnvVars::class);
|
||||
|
||||
if ($envVar === null) {
|
||||
@@ -54,14 +51,14 @@ class ReadEnvVarCommand extends Command
|
||||
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
|
||||
}
|
||||
|
||||
$input->setArgument('envVar', $envVar);
|
||||
$input->setArgument('env-var', $envVar);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$envVar = $input->getArgument('envVar');
|
||||
$output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument(description: 'The env var to read')] string $envVar,
|
||||
): int {
|
||||
$io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
@@ -18,6 +19,10 @@ use function array_map;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
#[AsCommand(
|
||||
name: DomainRedirectsCommand::NAME,
|
||||
description: 'Set specific "not found" redirects for individual domains.',
|
||||
)]
|
||||
class DomainRedirectsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'domain:redirects';
|
||||
@@ -27,18 +32,6 @@ class DomainRedirectsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set specific "not found" redirects for individual domains.')
|
||||
->addArgument(
|
||||
'domain',
|
||||
InputArgument::REQUIRED,
|
||||
'The domain authority to which you want to set the specific redirects',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
/** @var string|null $domain */
|
||||
@@ -67,10 +60,11 @@ class DomainRedirectsCommand extends Command
|
||||
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$domainAuthority = $input->getArgument('domain');
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')]
|
||||
string $domainAuthority,
|
||||
): int {
|
||||
$domain = $this->domainService->findByAuthority($domainAuthority);
|
||||
|
||||
$ask = static function (string $message, string|null $current) use ($io): string|null {
|
||||
|
||||
@@ -8,13 +8,17 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_map;
|
||||
|
||||
#[AsCommand(
|
||||
name: ListDomainsCommand::NAME,
|
||||
description: 'List all domains that have been ever used for some short URL',
|
||||
)]
|
||||
class ListDomainsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'domain:list';
|
||||
@@ -24,25 +28,17 @@ class ListDomainsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('List all domains that have been ever used for some short URL')
|
||||
->addOption(
|
||||
'show-redirects',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Option(
|
||||
'Will display an extra column with the information of the "not found" redirects for every domain.',
|
||||
shortcut: 'r',
|
||||
)]
|
||||
bool $showRedirects = false,
|
||||
): int {
|
||||
$domains = $this->domainService->listDomains();
|
||||
$showRedirects = $input->getOption('show-redirects');
|
||||
$commonFields = ['Domain', 'Is default'];
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
|
||||
$table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io);
|
||||
|
||||
$table->render(
|
||||
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
|
||||
@@ -53,7 +49,7 @@ class ListDomainsCommand extends Command
|
||||
? [
|
||||
...$commonValues,
|
||||
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
|
||||
]
|
||||
]
|
||||
: $commonValues;
|
||||
}, $domains),
|
||||
);
|
||||
|
||||
@@ -8,10 +8,10 @@ use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
|
||||
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
|
||||
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
@@ -19,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
|
||||
use function sprintf;
|
||||
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
|
||||
{
|
||||
$this->matomoEnabled = $matomoOptions->enabled;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$help = <<<HELP
|
||||
#[AsCommand(
|
||||
name: MatomoSendVisitsCommand::NAME,
|
||||
help: <<<HELP
|
||||
This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
|
||||
|
||||
Its intention is to allow you to configure Matomo at some point in time, and still have your whole visits
|
||||
@@ -54,32 +41,38 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
|
||||
|
||||
Send all visits created during 2022:
|
||||
<info>%command.name% --since 2022-01-01 --until 2022-12-31</info>
|
||||
HELP;
|
||||
HELP,
|
||||
)]
|
||||
class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
|
||||
{
|
||||
public const string NAME = 'integration:matomo:send-visits';
|
||||
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(sprintf(
|
||||
'%sSend existing visits to the configured matomo instance',
|
||||
$this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
|
||||
))
|
||||
->setHelp($help)
|
||||
->addOption(
|
||||
'since',
|
||||
's',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Only visits created since this date, inclusively, will be sent to Matomo',
|
||||
)
|
||||
->addOption(
|
||||
'until',
|
||||
'u',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Only visits created until this date, inclusively, will be sent to Matomo',
|
||||
);
|
||||
private readonly bool $matomoEnabled;
|
||||
private SymfonyStyle $io;
|
||||
|
||||
public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
|
||||
{
|
||||
$this->matomoEnabled = $matomoOptions->enabled;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$this->setDescription(sprintf(
|
||||
'%sSend existing visits to the configured matomo instance',
|
||||
$this->matomoEnabled ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
|
||||
));
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')]
|
||||
string|null $since = null,
|
||||
#[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')]
|
||||
string|null $until = null,
|
||||
): int {
|
||||
$this->io = $io;
|
||||
|
||||
if (! $this->matomoEnabled) {
|
||||
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
|
||||
@@ -87,8 +80,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
|
||||
}
|
||||
|
||||
// TODO Validate provided date formats
|
||||
$since = $input->getOption('since');
|
||||
$until = $input->getOption('until');
|
||||
$dateRange = buildDateRange(
|
||||
startDate: $since !== null ? Chronos::parse($since) : null,
|
||||
endDate: $until !== null ? Chronos::parse($until) : null,
|
||||
|
||||
@@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: DeleteExpiredShortUrlsCommand::NAME,
|
||||
description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
|
||||
)]
|
||||
class DeleteExpiredShortUrlsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'short-url:delete-expired';
|
||||
@@ -23,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
|
||||
)
|
||||
->addOption(
|
||||
'evaluate-max-visits',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'Also take into consideration short URLs which have reached their max amount of visits.',
|
||||
)
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation')
|
||||
->addOption(
|
||||
'dry-run',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'Delete short URLs with no confirmation',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$force = $input->getOption('force') || ! $input->isInteractive();
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits'));
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
InputInterface $input,
|
||||
#[Option('Also take into consideration short URLs which have reached their max amount of visits.')]
|
||||
bool $evaluateMaxVisits = false,
|
||||
#[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false,
|
||||
#[Option('Only check how many short URLs would be affected, without actually deleting them')]
|
||||
bool $dryRun = false,
|
||||
): int {
|
||||
$conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits);
|
||||
$force = $force || ! $input->isInteractive();
|
||||
|
||||
if (! $force && ! $dryRun) {
|
||||
$io->warning([
|
||||
@@ -69,6 +58,7 @@ class DeleteExpiredShortUrlsCommand extends Command
|
||||
|
||||
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
|
||||
$io->success(sprintf('%s expired short URLs have been deleted', $result));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\TagsOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
@@ -36,6 +37,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
private readonly TagsOption $tagsOption;
|
||||
|
||||
public function __construct(
|
||||
private readonly ShortUrlListServiceInterface $shortUrlService,
|
||||
@@ -44,6 +46,7 @@ class ListShortUrlsCommand extends Command
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'short URLs');
|
||||
$this->endDateOption = new EndDateOption($this, 'short URLs');
|
||||
$this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.');
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -70,17 +73,22 @@ class ListShortUrlsCommand extends Command
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
|
||||
)
|
||||
->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead')
|
||||
->addOption(
|
||||
'tags',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'A comma-separated list of tags to filter results.',
|
||||
'tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --tags is provided, returns only short URLs including ALL of them',
|
||||
)
|
||||
->addOption(
|
||||
'including-all-tags',
|
||||
'i',
|
||||
InputOption::VALUE_NONE,
|
||||
'If tags is provided, returns only short URLs having ALL tags.',
|
||||
'exclude-tag',
|
||||
'et',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'A list of tags that short URLs should not have.',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-tags-all',
|
||||
mode: InputOption::VALUE_NONE,
|
||||
description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
|
||||
)
|
||||
->addOption(
|
||||
'exclude-max-visits-reached',
|
||||
@@ -101,6 +109,12 @@ class ListShortUrlsCommand extends Command
|
||||
'The field from which you want to order by. '
|
||||
. 'Define ordering dir by passing ASC or DESC after "-" or ",".',
|
||||
)
|
||||
->addOption(
|
||||
'api-key-name',
|
||||
'kn',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'List only short URLs created by the API key matching provided name.',
|
||||
)
|
||||
->addOption(
|
||||
'show-tags',
|
||||
null,
|
||||
@@ -134,33 +148,32 @@ class ListShortUrlsCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$page = (int) $input->getOption('page');
|
||||
$searchTerm = $input->getOption('search-term');
|
||||
$domain = $input->getOption('domain');
|
||||
$tags = $input->getOption('tags');
|
||||
$tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||
$all = $input->getOption('all');
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
$tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true
|
||||
? TagsMode::ALL->value
|
||||
: TagsMode::ANY->value;
|
||||
$excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
|
||||
|
||||
$data = [
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $domain,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
|
||||
ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
|
||||
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
|
||||
ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input),
|
||||
ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
|
||||
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
|
||||
ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'),
|
||||
];
|
||||
|
||||
$all = $input->getOption('all');
|
||||
if ($all) {
|
||||
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
|
||||
}
|
||||
|
||||
$columnsMap = $this->resolveColumnsMap($input);
|
||||
do {
|
||||
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
|
||||
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
|
||||
@@ -168,7 +181,7 @@ class ListShortUrlsCommand extends Command
|
||||
|
||||
$continue = $result->hasNextPage() && $io->confirm(
|
||||
sprintf('Continue with page <options=bold>%s</>?', $page),
|
||||
false,
|
||||
default: false,
|
||||
);
|
||||
} while ($continue);
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')]
|
||||
class DeleteTagsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:delete';
|
||||
@@ -20,24 +20,13 @@ class DeleteTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes one or more tags.')
|
||||
->addOption(
|
||||
'name',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
'The name of the tags to delete',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tagNames = $input->getOption('name');
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [],
|
||||
): int {
|
||||
if (empty($tagNames)) {
|
||||
$io->warning('You have to provide at least one tag name');
|
||||
return self::INVALID;
|
||||
@@ -45,6 +34,7 @@ class DeleteTagsCommand extends Command
|
||||
|
||||
$this->tagService->deleteTags($tagNames);
|
||||
$io->success('Tags properly deleted');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,34 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'tag:visits';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
|
||||
) {
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$tag = $input->getArgument('tag');
|
||||
return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
|
||||
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,12 +8,13 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function array_map;
|
||||
|
||||
#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')]
|
||||
class ListTagsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:list';
|
||||
@@ -23,16 +24,9 @@ class ListTagsCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
public function __invoke(SymfonyStyle $io): int
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Lists existing tags.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\Renaming;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')]
|
||||
class RenameTagCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:rename';
|
||||
@@ -23,21 +23,11 @@ class RenameTagCommand extends Command
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Renames one existing tag.')
|
||||
->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
|
||||
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$oldName = $input->getArgument('oldName');
|
||||
$newName = $input->getArgument('newName');
|
||||
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('Current name of the tag.')] string $oldName,
|
||||
#[Argument('New name of the tag.')] string $newName,
|
||||
): int {
|
||||
try {
|
||||
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
|
||||
$io->success('Tag properly renamed.');
|
||||
|
||||
@@ -8,14 +8,17 @@ use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
DownloadGeoLiteDbCommand::NAME,
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.',
|
||||
)]
|
||||
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
|
||||
{
|
||||
public const string NAME = 'visit:download-db';
|
||||
@@ -28,19 +31,9 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
public function __invoke(SymfonyStyle $io): int
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription(
|
||||
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date '
|
||||
. 'copy if so.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$this->io = $io;
|
||||
|
||||
try {
|
||||
$result = $this->dbUpdater->checkDbUpdate($this);
|
||||
|
||||
@@ -4,23 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'visit:non-orphan';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
|
||||
) {
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
|
||||
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,11 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
@@ -19,6 +22,17 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
public const string NAME = 'visit:orphan';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
parent::__construct($visitsHelper);
|
||||
$this->domainOption = new DomainOption($this, sprintf(
|
||||
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
|
||||
Domain::DEFAULT_AUTHORITY,
|
||||
));
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
@@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
{
|
||||
$rawType = $input->getOption('type');
|
||||
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
|
||||
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
|
||||
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
type: $type,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
29
module/CLI/src/Input/DomainOption.php
Normal file
29
module/CLI/src/Input/DomainOption.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
final readonly class DomainOption
|
||||
{
|
||||
private const string NAME = 'domain';
|
||||
|
||||
public function __construct(Command $command, string $description)
|
||||
{
|
||||
$command->addOption(
|
||||
name: self::NAME,
|
||||
shortcut: 'd',
|
||||
mode: InputOption::VALUE_REQUIRED,
|
||||
description: $description,
|
||||
);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input): string|null
|
||||
{
|
||||
return $input->getOption(self::NAME);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,10 @@ use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
final readonly class ShortUrlDataInput
|
||||
{
|
||||
private readonly TagsOption $tagsOption;
|
||||
|
||||
public function __construct(Command $command, private bool $longUrlAsOption = false)
|
||||
{
|
||||
if ($longUrlAsOption) {
|
||||
@@ -28,13 +25,9 @@ final readonly class ShortUrlDataInput
|
||||
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
|
||||
}
|
||||
|
||||
$this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL');
|
||||
|
||||
$command
|
||||
->addOption(
|
||||
ShortUrlDataOption::TAGS->value,
|
||||
ShortUrlDataOption::TAGS->shortcut(),
|
||||
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
|
||||
'Tags to apply to the short URL',
|
||||
)
|
||||
->addOption(
|
||||
ShortUrlDataOption::VALID_SINCE->value,
|
||||
ShortUrlDataOption::VALID_SINCE->shortcut(),
|
||||
@@ -117,9 +110,8 @@ final readonly class ShortUrlDataInput
|
||||
$maxVisits = $input->getOption('max-visits');
|
||||
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
}
|
||||
if (ShortUrlDataOption::TAGS->wasProvided($input)) {
|
||||
$tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
|
||||
$data[ShortUrlInputFilter::TAGS] = $tags;
|
||||
if ($this->tagsOption->exists($input)) {
|
||||
$data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
|
||||
}
|
||||
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
|
||||
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
|
||||
|
||||
@@ -10,7 +10,6 @@ use function sprintf;
|
||||
|
||||
enum ShortUrlDataOption: string
|
||||
{
|
||||
case TAGS = 'tags';
|
||||
case VALID_SINCE = 'valid-since';
|
||||
case VALID_UNTIL = 'valid-until';
|
||||
case MAX_VISITS = 'max-visits';
|
||||
@@ -21,7 +20,6 @@ enum ShortUrlDataOption: string
|
||||
public function shortcut(): string|null
|
||||
{
|
||||
return match ($this) {
|
||||
self::TAGS => 't',
|
||||
self::VALID_SINCE => 's',
|
||||
self::VALID_UNTIL => 'u',
|
||||
self::MAX_VISITS => 'm',
|
||||
|
||||
51
module/CLI/src/Input/TagsOption.php
Normal file
51
module/CLI/src/Input/TagsOption.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
|
||||
use function Shlinkio\Shlink\Core\splitByComma;
|
||||
|
||||
readonly class TagsOption
|
||||
{
|
||||
public function __construct(Command $command, string $description)
|
||||
{
|
||||
$command
|
||||
->addOption(
|
||||
'tag',
|
||||
't',
|
||||
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
$description,
|
||||
)
|
||||
->addOption(
|
||||
'tags',
|
||||
mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
|
||||
description: '[DEPRECATED] Use --tag instead',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether tags have been set or not, via `--tag`, `-t` or the deprecated `--tags`
|
||||
*/
|
||||
public function exists(InputInterface $input): bool
|
||||
{
|
||||
return $input->hasParameterOption(['--tag', '-t']) || $input->hasParameterOption('--tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function get(InputInterface $input): array
|
||||
{
|
||||
// FIXME DEPRECATED Remove support for comma-separated tags in next major release
|
||||
$tags = [...$input->getOption('tag'), ...$input->getOption('tags')];
|
||||
return array_unique(flatten(array_map(splitByComma(...), $tags)));
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,15 @@ class ListShortUrlsTest extends CliTestCase
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
yield 'exclude tags' => [['--exclude-tag=foo'], <<<OUTPUT
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| Short Code | Title | Short URL | Long URL | Date created | Visits count |
|
||||
+--------------------+-------+-------------------------------------------+----------------------------------+---------------------------+--------------+
|
||||
| custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 |
|
||||
| custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 |
|
||||
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
|
||||
+--------------------+-------+--------------------------------------- Page 1 of 1 --------------------------+---------------------------+--------------+
|
||||
OUTPUT];
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
|
||||
100
module/CLI/test/Command/Api/DeleteKeyCommandTest.php
Normal file
100
module/CLI/test/Command/Api/DeleteKeyCommandTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\Api;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\Api\DeleteKeyCommand;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class DeleteKeyCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private MockObject & ApiKeyServiceInterface $apiKeyService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new DeleteKeyCommand($this->apiKeyService));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute([], ['interactive' => false]);
|
||||
|
||||
self::assertEquals(Command::INVALID, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function confirmationIsSkippedInNonInteractiveMode(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['name' => 'key to delete'], ['interactive' => false]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(Command::SUCCESS, $exitCode);
|
||||
self::assertStringNotContainsString('Are you sure you want to delete the API key?', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function keyIsNotDeletedIfConfirmationIsCancelled(): void
|
||||
{
|
||||
$this->apiKeyService->expects($this->never())->method('deleteByName');
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$this->commandTester->setInputs(['no']);
|
||||
$exitCode = $this->commandTester->execute(['name' => 'key_to_delete']);
|
||||
|
||||
self::assertEquals(Command::INVALID, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void
|
||||
{
|
||||
$name = 'the key to delete';
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($name);
|
||||
$this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)),
|
||||
ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')),
|
||||
]);
|
||||
|
||||
$this->commandTester->setInputs([$name, 'y']);
|
||||
$exitCode = $this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString('What API key do you want to delete?', $output);
|
||||
self::assertStringContainsString('API key "the key to delete" properly deleted', $output);
|
||||
self::assertEquals(Command::SUCCESS, $exitCode);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfDisableByKeyThrowsException(): void
|
||||
{
|
||||
$apiKey = 'key to delete';
|
||||
$e = ApiKeyNotFoundException::forName($apiKey);
|
||||
$this->apiKeyService->expects($this->once())->method('deleteByName')->with($apiKey)->willThrowException($e);
|
||||
$this->apiKeyService->expects($this->never())->method('listKeys');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['name' => $apiKey]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString($e->getMessage(), $output);
|
||||
self::assertEquals(Command::FAILURE, $exitCode);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class InitialApiKeyCommandTest extends TestCase
|
||||
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
|
||||
|
||||
$this->commandTester->execute(
|
||||
['apiKey' => 'the_key'],
|
||||
['api-key' => 'the_key'],
|
||||
['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL],
|
||||
);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -28,13 +28,13 @@ class ReadEnvVarCommandTest extends TestCase
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('foo is not a valid Shlink environment variable');
|
||||
|
||||
$this->commandTester->execute(['envVar' => 'foo']);
|
||||
$this->commandTester->execute(['env-var' => 'foo']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function valueIsPrintedIfProvidedEnvVarIsValid(): void
|
||||
{
|
||||
$this->commandTester->execute(['envVar' => EnvVars::BASE_PATH->value]);
|
||||
$this->commandTester->execute(['env-var' => EnvVars::BASE_PATH->value]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringNotContainsString('Select the env var to read', $output);
|
||||
|
||||
@@ -25,8 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function explode;
|
||||
|
||||
class ListShortUrlsCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
@@ -209,6 +207,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
string $tagsMode,
|
||||
string|null $startDate = null,
|
||||
string|null $endDate = null,
|
||||
array $excludeTags = [],
|
||||
string $excludeTagsMode = TagsMode::ANY->value,
|
||||
string|null $apiKeyName = null,
|
||||
): void {
|
||||
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
@@ -217,6 +218,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
'tagsMode' => $tagsMode,
|
||||
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||
'excludeTags' => $excludeTags,
|
||||
'excludeTagsMode' => $excludeTagsMode,
|
||||
'apiKeyName' => $apiKeyName,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
@@ -230,10 +234,10 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value];
|
||||
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
|
||||
yield [
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
|
||||
$page,
|
||||
$searchTerm,
|
||||
explode(',', $tags),
|
||||
$tags,
|
||||
TagsMode::ANY->value,
|
||||
];
|
||||
yield [
|
||||
@@ -262,6 +266,29 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$startDate,
|
||||
$endDate,
|
||||
];
|
||||
yield [
|
||||
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
null,
|
||||
null,
|
||||
['foo', 'bar'],
|
||||
TagsMode::ALL->value,
|
||||
];
|
||||
yield [
|
||||
['--api-key-name' => 'foo'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
TagsMode::ANY->value,
|
||||
'foo',
|
||||
];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideOrderBy')]
|
||||
|
||||
@@ -36,8 +36,8 @@ class RenameTagCommandTest extends TestCase
|
||||
)->willThrowException(TagNotFoundException::fromTag('foo'));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
'old-name' => $oldName,
|
||||
'new-name' => $newName,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -54,8 +54,8 @@ class RenameTagCommandTest extends TestCase
|
||||
)->willReturn(new Tag($newName));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'oldName' => $oldName,
|
||||
'newName' => $newName,
|
||||
'old-name' => $oldName,
|
||||
'new-name' => $newName,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class ShlinkTableTest extends TestCase
|
||||
|
||||
$ref = new ReflectionObject($instance);
|
||||
$baseTable = $ref->getProperty('baseTable');
|
||||
$baseTable->setAccessible(true);
|
||||
|
||||
self::assertInstanceOf(Table::class, $baseTable->getValue($instance));
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ return [
|
||||
],
|
||||
Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class],
|
||||
Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Visit\VisitsStatsHelper::class => ['em', Config\Options\UrlShortenerOptions::class],
|
||||
Tag\TagService::class => ['em', Tag\Repository\TagRepository::class],
|
||||
ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
|
||||
@@ -41,6 +41,8 @@ enum EnvVars: string
|
||||
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
|
||||
case REDIS_SERVERS = 'REDIS_SERVERS';
|
||||
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
|
||||
case REDIS_SERVERS_USER = 'REDIS_SERVERS_USER';
|
||||
case REDIS_SERVERS_PASSWORD = 'REDIS_SERVERS_PASSWORD';
|
||||
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
|
||||
case MERCURE_ENABLED = 'MERCURE_ENABLED';
|
||||
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
|
||||
|
||||
@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
|
||||
abstract class AbstractInfinitePaginableListParams
|
||||
{
|
||||
private const FIRST_PAGE = 1;
|
||||
private const int FIRST_PAGE = 1;
|
||||
|
||||
public readonly int $page;
|
||||
public readonly int $itemsPerPage;
|
||||
|
||||
@@ -12,21 +12,27 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
|
||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
||||
|
||||
final class ShortUrlsParams
|
||||
/**
|
||||
* Represents all the params that can be used to filter a list of short URLs
|
||||
*/
|
||||
final readonly class ShortUrlsParams
|
||||
{
|
||||
public const DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
public const int DEFAULT_ITEMS_PER_PAGE = 10;
|
||||
|
||||
private function __construct(
|
||||
public readonly int $page,
|
||||
public readonly int $itemsPerPage,
|
||||
public readonly string|null $searchTerm,
|
||||
public readonly array $tags,
|
||||
public readonly Ordering $orderBy,
|
||||
public readonly DateRange|null $dateRange,
|
||||
public readonly bool $excludeMaxVisitsReached,
|
||||
public readonly bool $excludePastValidUntil,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly string|null $domain = null,
|
||||
public int $page,
|
||||
public int $itemsPerPage,
|
||||
public string|null $searchTerm,
|
||||
public array $tags,
|
||||
public Ordering $orderBy,
|
||||
public DateRange|null $dateRange,
|
||||
public bool $excludeMaxVisitsReached,
|
||||
public bool $excludePastValidUntil,
|
||||
public TagsMode $tagsMode = TagsMode::ANY,
|
||||
public string|null $domain = null,
|
||||
public array $excludeTags = [],
|
||||
public TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
public string|null $apiKeyName = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -61,6 +67,11 @@ final class ShortUrlsParams
|
||||
excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL),
|
||||
tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)),
|
||||
domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN),
|
||||
excludeTags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS),
|
||||
excludeTagsMode: self::resolveTagsMode(
|
||||
$inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE),
|
||||
),
|
||||
apiKeyName: $inputFilter->getValue(ShortUrlsParamsInputFilter::API_KEY_NAME),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation;
|
||||
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator\InArray;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
@@ -19,14 +20,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
public const string PAGE = 'page';
|
||||
public const string SEARCH_TERM = 'searchTerm';
|
||||
public const string TAGS = 'tags';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string EXCLUDE_TAGS = 'excludeTags';
|
||||
public const string EXCLUDE_TAGS_MODE = 'excludeTagsMode';
|
||||
public const string START_DATE = 'startDate';
|
||||
public const string END_DATE = 'endDate';
|
||||
public const string ITEMS_PER_PAGE = 'itemsPerPage';
|
||||
public const string TAGS_MODE = 'tagsMode';
|
||||
public const string ORDER_BY = 'orderBy';
|
||||
public const string EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached';
|
||||
public const string EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil';
|
||||
public const string DOMAIN = 'domain';
|
||||
public const string API_KEY_NAME = 'apiKeyName';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
@@ -45,13 +49,10 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
$this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS));
|
||||
|
||||
$this->add(InputFactory::tags(self::TAGS));
|
||||
$this->add($this->createTagsModeInput(self::TAGS_MODE));
|
||||
|
||||
$tagsMode = InputFactory::basic(self::TAGS_MODE);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
$this->add($tagsMode);
|
||||
$this->add(InputFactory::tags(self::EXCLUDE_TAGS));
|
||||
$this->add($this->createTagsModeInput(self::EXCLUDE_TAGS_MODE));
|
||||
|
||||
$this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class)));
|
||||
|
||||
@@ -59,5 +60,17 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
$this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL));
|
||||
|
||||
$this->add(InputFactory::basic(self::DOMAIN));
|
||||
$this->add(InputFactory::basic(self::API_KEY_NAME));
|
||||
}
|
||||
|
||||
private function createTagsModeInput(string $name): Input
|
||||
{
|
||||
$tagsMode = InputFactory::basic($name);
|
||||
$tagsMode->getValidatorChain()->attach(new InArray([
|
||||
'haystack' => enumValues(TagsMode::class),
|
||||
'strict' => InArray::COMPARE_STRICT,
|
||||
]));
|
||||
|
||||
return $tagsMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,22 +15,33 @@ use function strtolower;
|
||||
class ShortUrlsCountFiltering
|
||||
{
|
||||
public readonly bool $searchIncludesDefaultDomain;
|
||||
public readonly string|null $apiKeyName;
|
||||
|
||||
/**
|
||||
* @param $defaultDomain - Used only to determine if search term includes default domain
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string|null $searchTerm = null,
|
||||
public readonly array $tags = [],
|
||||
public readonly TagsMode|null $tagsMode = null,
|
||||
public readonly TagsMode $tagsMode = TagsMode::ANY,
|
||||
public readonly DateRange|null $dateRange = null,
|
||||
public readonly bool $excludeMaxVisitsReached = false,
|
||||
public readonly bool $excludePastValidUntil = false,
|
||||
public readonly ApiKey|null $apiKey = null,
|
||||
string|null $defaultDomain = null,
|
||||
public readonly string|null $domain = null,
|
||||
public readonly array $excludeTags = [],
|
||||
public readonly TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
string|null $apiKeyName = null,
|
||||
) {
|
||||
$this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains(
|
||||
strtolower($defaultDomain),
|
||||
strtolower($searchTerm),
|
||||
);
|
||||
|
||||
// Filtering by API key name is only allowed if the API key used in the request is an admin one, or it matches
|
||||
// the API key name
|
||||
$this->apiKeyName = $apiKey?->name === $apiKeyName || ApiKey::isAdmin($apiKey) ? $apiKeyName : null;
|
||||
}
|
||||
|
||||
public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self
|
||||
@@ -45,6 +56,9 @@ class ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$params->domain,
|
||||
$params->excludeTags,
|
||||
$params->excludeTagsMode,
|
||||
$params->apiKeyName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,25 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int|null $limit = null,
|
||||
public readonly int|null $offset = null,
|
||||
public readonly Ordering $orderBy = new Ordering(),
|
||||
string|null $searchTerm = null,
|
||||
array $tags = [],
|
||||
TagsMode|null $tagsMode = null,
|
||||
TagsMode $tagsMode = TagsMode::ANY,
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeMaxVisitsReached = false,
|
||||
bool $excludePastValidUntil = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
// Used only to determine if search term includes default domain
|
||||
string|null $defaultDomain = null,
|
||||
string|null $domain = null,
|
||||
array $excludeTags = [],
|
||||
TagsMode $excludeTagsMode = TagsMode::ANY,
|
||||
string|null $apiKeyName = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
$searchTerm,
|
||||
@@ -37,6 +42,9 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$domain,
|
||||
$excludeTags,
|
||||
$excludeTagsMode,
|
||||
$apiKeyName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +68,9 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering
|
||||
$apiKey,
|
||||
$defaultDomain,
|
||||
$params->domain,
|
||||
$params->excludeTags,
|
||||
$params->excludeTagsMode,
|
||||
$params->apiKeyName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
|
||||
$searchTerm = $filtering->searchTerm;
|
||||
$tags = $filtering->tags;
|
||||
$tagsMode = $filtering->tagsMode;
|
||||
$excludeTags = $filtering->excludeTags;
|
||||
$excludeTagsMode = $filtering->excludeTagsMode;
|
||||
|
||||
if (! empty($searchTerm)) {
|
||||
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
|
||||
if (empty($tags)) {
|
||||
@@ -125,7 +129,6 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
}
|
||||
|
||||
// Apply tag conditions, only when not filtering by all provided tags
|
||||
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
|
||||
if (empty($tags) || $tagsMode === TagsMode::ANY) {
|
||||
$conditions[] = $qb->expr()->like('t.name', ':searchPattern');
|
||||
}
|
||||
@@ -134,21 +137,33 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||
}
|
||||
|
||||
// Filter by tags if provided
|
||||
if (! empty($tags)) {
|
||||
$tagsMode = $filtering->tagsMode ?? TagsMode::ANY;
|
||||
$tagsMode === TagsMode::ANY
|
||||
? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
|
||||
: $this->joinAllTags($qb, $tags);
|
||||
if ($tagsMode === TagsMode::ANY) {
|
||||
$qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags));
|
||||
} else {
|
||||
$this->joinAllTags($qb, $tags);
|
||||
}
|
||||
}
|
||||
|
||||
if ($filtering->domain !== null) {
|
||||
if ($filtering->domain === Domain::DEFAULT_AUTHORITY) {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
if (! empty($excludeTags)) {
|
||||
$subQb = $this->getEntityManager()->createQueryBuilder();
|
||||
$subQb->select('s2.id')
|
||||
->from(ShortUrl::class, 's2');
|
||||
|
||||
if ($excludeTagsMode === TagsMode::ANY) {
|
||||
$subQb->join('s2.tags', 't2')->andWhere($qb->expr()->in('t2.name', $excludeTags));
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $filtering->domain);
|
||||
$this->joinAllTags($subQb, $excludeTags, shortUrlsAlias: 's2', boundParamsQb: $qb);
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->notIn('s.id', $subQb->getDQL()));
|
||||
}
|
||||
|
||||
if ($filtering->domain === Domain::DEFAULT_AUTHORITY) {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
} elseif ($filtering->domain !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $filtering->domain);
|
||||
}
|
||||
|
||||
if ($filtering->excludeMaxVisitsReached) {
|
||||
@@ -173,17 +188,40 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||
->setParameter('minValidUntil', Chronos::now()->toDateTimeString());
|
||||
}
|
||||
|
||||
$apiKeyName = $filtering->apiKeyName;
|
||||
if ($apiKeyName !== null) {
|
||||
$qb
|
||||
->join('s.authorApiKey', 'a')
|
||||
->andWhere($qb->expr()->eq('a.name', ':apiKeyName'))
|
||||
->setParameter('apiKeyName', $apiKeyName);
|
||||
}
|
||||
|
||||
$this->applySpecification($qb, $filtering->apiKey?->spec(), 's');
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function joinAllTags(QueryBuilder $qb, array $tags): void
|
||||
{
|
||||
/**
|
||||
* @param $boundParamsQb - The query builder in which params should be bound, in case the main provided QB is going
|
||||
* to be used as a sub query, since params need to be bound in the parent query.
|
||||
* Defaults to the main $qb
|
||||
*/
|
||||
private function joinAllTags(
|
||||
QueryBuilder $qb,
|
||||
array $tags,
|
||||
string $shortUrlsAlias = 's',
|
||||
QueryBuilder|null $boundParamsQb = null,
|
||||
): void {
|
||||
$boundParamsQb ??= $qb;
|
||||
foreach ($tags as $index => $tag) {
|
||||
$alias = 't_' . $index;
|
||||
$qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
|
||||
->setParameter('tag' . $index, $tag);
|
||||
$alias = 't_' . $index . $shortUrlsAlias;
|
||||
$qb->join(
|
||||
$shortUrlsAlias . '.tags',
|
||||
$alias,
|
||||
Join::WITH,
|
||||
$alias . '.name = :tag' . $index . $shortUrlsAlias,
|
||||
);
|
||||
$boundParamsQb->setParameter('tag' . $index . $shortUrlsAlias, $tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,22 @@ use ValueError;
|
||||
use function Shlinkio\Shlink\Core\enumToString;
|
||||
use function sprintf;
|
||||
|
||||
final class OrphanVisitsParams extends VisitsParams
|
||||
final class OrphanVisitsParams extends WithDomainVisitsParams
|
||||
{
|
||||
public function __construct(
|
||||
DateRange|null $dateRange = null,
|
||||
int|null $page = null,
|
||||
int|null $itemsPerPage = null,
|
||||
bool $excludeBots = false,
|
||||
string|null $domain = null,
|
||||
public readonly OrphanVisitType|null $type = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots);
|
||||
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots, $domain);
|
||||
}
|
||||
|
||||
public static function fromVisitsParamsAndRawData(VisitsParams $visitsParams, array $query): self
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$visitsParams = WithDomainVisitsParams::fromRawData($query);
|
||||
$type = $query['type'] ?? null;
|
||||
|
||||
return new self(
|
||||
@@ -30,6 +32,7 @@ final class OrphanVisitsParams extends VisitsParams
|
||||
page: $visitsParams->page,
|
||||
itemsPerPage: $visitsParams->itemsPerPage,
|
||||
excludeBots: $visitsParams->excludeBots,
|
||||
domain: $visitsParams->domain,
|
||||
type: $type !== null ? self::parseType($type) : null,
|
||||
);
|
||||
}
|
||||
|
||||
31
module/Core/src/Visit/Model/WithDomainVisitsParams.php
Normal file
31
module/Core/src/Visit/Model/WithDomainVisitsParams.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
class WithDomainVisitsParams extends VisitsParams
|
||||
{
|
||||
public function __construct(
|
||||
DateRange|null $dateRange = null,
|
||||
int|null $page = null,
|
||||
int|null $itemsPerPage = null,
|
||||
bool $excludeBots = false,
|
||||
public readonly string|null $domain = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$visitsParams = VisitsParams::fromRawData($query);
|
||||
|
||||
return new self(
|
||||
dateRange: $visitsParams->dateRange,
|
||||
page: $visitsParams->page,
|
||||
itemsPerPage: $visitsParams->itemsPerPage,
|
||||
excludeBots: $visitsParams->excludeBots,
|
||||
domain: $query['domain'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
@@ -17,26 +17,28 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisitRepositoryInterface $repo,
|
||||
private readonly VisitsParams $params,
|
||||
private readonly WithDomainVisitsParams $params,
|
||||
private readonly ApiKey|null $apiKey,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->repo->countNonOrphanVisits(new VisitsCountFiltering(
|
||||
return $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
|
||||
$this->params->dateRange,
|
||||
$this->params->excludeBots,
|
||||
$this->apiKey,
|
||||
$this->params->domain,
|
||||
));
|
||||
}
|
||||
|
||||
public function getSlice(int $offset, int $length): iterable
|
||||
{
|
||||
return $this->repo->findNonOrphanVisits(new VisitsListFiltering(
|
||||
return $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
|
||||
$this->params->dateRange,
|
||||
$this->params->excludeBots,
|
||||
$this->apiKey,
|
||||
$this->params->domain,
|
||||
$length,
|
||||
$offset,
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
@@ -19,6 +20,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||
private readonly VisitRepositoryInterface $repo,
|
||||
private readonly OrphanVisitsParams $params,
|
||||
private readonly ApiKey|null $apiKey,
|
||||
private readonly UrlShortenerOptions $options,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -28,7 +30,9 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||
dateRange: $this->params->dateRange,
|
||||
excludeBots: $this->params->excludeBots,
|
||||
apiKey: $this->apiKey,
|
||||
domain: $this->params->domain,
|
||||
type: $this->params->type,
|
||||
defaultDomain: $this->options->defaultDomain,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -38,7 +42,9 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
|
||||
dateRange: $this->params->dateRange,
|
||||
excludeBots: $this->params->excludeBots,
|
||||
apiKey: $this->apiKey,
|
||||
domain: $this->params->domain,
|
||||
type: $this->params->type,
|
||||
defaultDomain: $this->options->defaultDomain,
|
||||
limit: $length,
|
||||
offset: $offset,
|
||||
));
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
@@ -18,7 +18,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
public function __construct(
|
||||
private readonly VisitRepositoryInterface $visitRepository,
|
||||
private readonly string $tag,
|
||||
private readonly VisitsParams $params,
|
||||
private readonly WithDomainVisitsParams $params,
|
||||
private readonly ApiKey|null $apiKey,
|
||||
) {
|
||||
}
|
||||
@@ -27,10 +27,11 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
return $this->visitRepository->findVisitsByTag(
|
||||
$this->tag,
|
||||
new VisitsListFiltering(
|
||||
new WithDomainVisitsListFiltering(
|
||||
$this->params->dateRange,
|
||||
$this->params->excludeBots,
|
||||
$this->apiKey,
|
||||
$this->params->domain,
|
||||
$length,
|
||||
$offset,
|
||||
),
|
||||
@@ -41,10 +42,11 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
return $this->visitRepository->countVisitsByTag(
|
||||
$this->tag,
|
||||
new VisitsCountFiltering(
|
||||
new WithDomainVisitsCountFiltering(
|
||||
$this->params->dateRange,
|
||||
$this->params->excludeBots,
|
||||
$this->apiKey,
|
||||
$this->params->domain,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class OrphanVisitsCountFiltering extends VisitsCountFiltering
|
||||
class OrphanVisitsCountFiltering extends WithDomainVisitsCountFiltering
|
||||
{
|
||||
public function __construct(
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeBots = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
string|null $domain = null,
|
||||
public readonly OrphanVisitType|null $type = null,
|
||||
public readonly string $defaultDomain = '',
|
||||
) {
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey);
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey, $domain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ final class OrphanVisitsListFiltering extends OrphanVisitsCountFiltering
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeBots = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
string|null $domain = null,
|
||||
OrphanVisitType|null $type = null,
|
||||
string $defaultDomain = '',
|
||||
public readonly int|null $limit = null,
|
||||
public readonly int|null $offset = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey, $type);
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey, $domain, $type, $defaultDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class WithDomainVisitsCountFiltering extends VisitsCountFiltering
|
||||
{
|
||||
public function __construct(
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeBots = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
public readonly string|null $domain = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Persistence;
|
||||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
final class WithDomainVisitsListFiltering extends WithDomainVisitsCountFiltering
|
||||
{
|
||||
public function __construct(
|
||||
DateRange|null $dateRange = null,
|
||||
bool $excludeBots = false,
|
||||
ApiKey|null $apiKey = null,
|
||||
string|null $domain = null,
|
||||
public readonly int|null $limit = null,
|
||||
public readonly int|null $offset = null,
|
||||
) {
|
||||
parent::__construct($dateRange, $excludeBots, $apiKey, $domain);
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits;
|
||||
use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Role;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
@@ -69,13 +69,13 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array
|
||||
public function findVisitsByTag(string $tag, WithDomainVisitsListFiltering $filtering): array
|
||||
{
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $filtering);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
|
||||
}
|
||||
|
||||
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int
|
||||
public function countVisitsByTag(string $tag, WithDomainVisitsCountFiltering $filtering): int
|
||||
{
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $filtering);
|
||||
$qb->select('COUNT(v.id)');
|
||||
@@ -83,19 +83,29 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, VisitsCountFiltering $filtering): QueryBuilder
|
||||
private function createVisitsByTagQueryBuilder(string $tag, WithDomainVisitsCountFiltering $filtering): QueryBuilder
|
||||
{
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag)));
|
||||
->where($qb->expr()->eq('t.name', $conn->quote($tag)));
|
||||
|
||||
if ($filtering->excludeBots) {
|
||||
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
|
||||
}
|
||||
|
||||
$domain = $filtering->domain;
|
||||
if ($domain === Domain::DEFAULT_AUTHORITY) {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
} elseif ($domain !== null) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', $conn->quote($domain)));
|
||||
}
|
||||
|
||||
$this->applyDatesInline($qb, $filtering->dateRange);
|
||||
$this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v');
|
||||
|
||||
@@ -149,15 +159,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
|
||||
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
if ($filtering->type) {
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
$qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value)));
|
||||
}
|
||||
|
||||
$qb = $this->createOrphanVisitsQueryBuilder($filtering);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
|
||||
}
|
||||
|
||||
@@ -167,36 +169,78 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering));
|
||||
$qb = $this->createOrphanVisitsQueryBuilder($filtering);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createOrphanVisitsQueryBuilder(OrphanVisitsCountFiltering $filtering): QueryBuilder
|
||||
{
|
||||
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||
$qb->andWhere($qb->expr()->isNull('v.shortUrl'));
|
||||
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
if ($filtering->type) {
|
||||
$qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value)));
|
||||
}
|
||||
|
||||
$domain = $filtering->domain;
|
||||
$domain = $domain === Domain::DEFAULT_AUTHORITY ? $filtering->defaultDomain : $domain;
|
||||
if ($domain !== null) {
|
||||
$qb->andWhere($qb->expr()->like('v.visitedUrl', $conn->quote('%' . $domain . '%')));
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findNonOrphanVisits(VisitsListFiltering $filtering): array
|
||||
public function findNonOrphanVisits(WithDomainVisitsListFiltering $filtering): array
|
||||
{
|
||||
$qb = $this->createNonOrphanVisitsQueryBuilder($filtering);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
|
||||
}
|
||||
|
||||
public function countNonOrphanVisits(WithDomainVisitsCountFiltering $filtering): int
|
||||
{
|
||||
$qb = $this->createNonOrphanVisitsQueryBuilder($filtering);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createNonOrphanVisitsQueryBuilder(WithDomainVisitsCountFiltering $filtering): QueryBuilder
|
||||
{
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
$qb = $this->createAllVisitsQueryBuilder($filtering);
|
||||
$qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
|
||||
|
||||
$apiKey = $filtering->apiKey;
|
||||
if (ApiKey::isShortUrlRestricted($apiKey)) {
|
||||
$domain = $filtering->domain;
|
||||
if (ApiKey::isShortUrlRestricted($apiKey) || $domain !== null) {
|
||||
$qb->join('v.shortUrl', 's');
|
||||
}
|
||||
|
||||
if ($domain === Domain::DEFAULT_AUTHORITY) {
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
} elseif ($domain !== null) {
|
||||
$qb->join('s.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', $conn->quote($domain)));
|
||||
}
|
||||
|
||||
$this->applySpecification($qb, $apiKey?->inlinedSpec(), 'v');
|
||||
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset);
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int
|
||||
private function createAllVisitsQueryBuilder(VisitsCountFiltering $filtering): QueryBuilder
|
||||
{
|
||||
return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering));
|
||||
}
|
||||
|
||||
private function createAllVisitsQueryBuilder(VisitsListFiltering|OrphanVisitsListFiltering $filtering): QueryBuilder
|
||||
{
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
|
||||
// Since they are not provided by the caller, it's reasonably safe
|
||||
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
|
||||
// Since they are not provided by the caller, it's reasonably safe.
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v');
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
|
||||
/**
|
||||
* @extends ObjectRepository<Visit>
|
||||
@@ -28,9 +30,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array;
|
||||
public function findVisitsByTag(string $tag, WithDomainVisitsListFiltering $filtering): array;
|
||||
|
||||
public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int;
|
||||
public function countVisitsByTag(string $tag, WithDomainVisitsCountFiltering $filtering): int;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
@@ -49,9 +51,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findNonOrphanVisits(VisitsListFiltering $filtering): array;
|
||||
public function findNonOrphanVisits(WithDomainVisitsListFiltering $filtering): array;
|
||||
|
||||
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int;
|
||||
public function countNonOrphanVisits(WithDomainVisitsCountFiltering $filtering): int;
|
||||
|
||||
public function findMostRecentOrphanVisit(): Visit|null;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Spec\InDateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
|
||||
|
||||
class CountOfNonOrphanVisits extends BaseSpecification
|
||||
{
|
||||
public function __construct(private readonly VisitsCountFiltering $filtering)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
$conditions = [
|
||||
Spec::isNotNull('shortUrl'),
|
||||
new InDateRange($this->filtering->dateRange),
|
||||
];
|
||||
|
||||
if ($this->filtering->excludeBots) {
|
||||
$conditions[] = Spec::eq('potentialBot', false);
|
||||
}
|
||||
|
||||
$apiKey = $this->filtering->apiKey;
|
||||
if ($apiKey !== null) {
|
||||
$conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl');
|
||||
}
|
||||
|
||||
return Spec::countOf(Spec::andX(...$conditions));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Spec;
|
||||
|
||||
use Happyr\DoctrineSpecification\Spec;
|
||||
use Happyr\DoctrineSpecification\Specification\BaseSpecification;
|
||||
use Happyr\DoctrineSpecification\Specification\Specification;
|
||||
use Shlinkio\Shlink\Core\Spec\InDateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||
|
||||
class CountOfOrphanVisits extends BaseSpecification
|
||||
{
|
||||
public function __construct(private readonly OrphanVisitsCountFiltering $filtering)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function getSpec(): Specification
|
||||
{
|
||||
$conditions = [
|
||||
Spec::isNull('shortUrl'),
|
||||
new InDateRange($this->filtering->dateRange),
|
||||
];
|
||||
|
||||
if ($this->filtering->excludeBots) {
|
||||
$conditions[] = Spec::eq('potentialBot', false);
|
||||
}
|
||||
|
||||
if ($this->filtering->type) {
|
||||
$conditions[] = Spec::eq('type', $this->filtering->type->value);
|
||||
}
|
||||
|
||||
return Spec::countOf(Spec::andX(...$conditions));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Visit;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Pagerfanta\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
@@ -23,6 +24,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
|
||||
@@ -37,7 +39,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em)
|
||||
public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $options)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator
|
||||
public function visitsForTag(string $tag, WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
@@ -127,10 +129,13 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
|
||||
return $this->createPaginator(
|
||||
new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey, $this->options),
|
||||
$params,
|
||||
);
|
||||
}
|
||||
|
||||
public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator
|
||||
public function nonOrphanVisits(WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator
|
||||
{
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
interface VisitsStatsHelperInterface
|
||||
@@ -33,7 +34,7 @@ interface VisitsStatsHelperInterface
|
||||
* @return Paginator<Visit>
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator;
|
||||
public function visitsForTag(string $tag, WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator;
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
@@ -49,5 +50,5 @@ interface VisitsStatsHelperInterface
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator;
|
||||
public function nonOrphanVisits(WithDomainVisitsParams $params, ApiKey|null $apiKey = null): Paginator;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepository;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
use function array_map;
|
||||
@@ -82,7 +85,6 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
|
||||
$foo2->setVisits(new ArrayCollection($visits2));
|
||||
$ref = new ReflectionObject($foo2);
|
||||
$dateProp = $ref->getProperty('dateCreated');
|
||||
$dateProp->setAccessible(true);
|
||||
$dateProp->setValue($foo2, Chronos::now()->subDays(5));
|
||||
$this->getEntityManager()->persist($foo2);
|
||||
|
||||
@@ -239,6 +241,24 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
|
||||
self::assertEquals(0, $this->repo->countList(
|
||||
new ShortUrlsCountFiltering(tags: ['foo', 'bar', 'baz'], tagsMode: TagsMode::ALL),
|
||||
));
|
||||
|
||||
self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering(excludeTags: ['foo'])));
|
||||
self::assertEquals(0, $this->repo->countList(new ShortUrlsCountFiltering(excludeTags: ['foo', 'bar'])));
|
||||
self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
excludeTags: ['foo', 'bar'],
|
||||
excludeTagsMode: TagsMode::ALL,
|
||||
)));
|
||||
|
||||
self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering(tags: ['foo'], excludeTags: ['bar'])));
|
||||
self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
tags: ['foo'],
|
||||
excludeTags: ['bar', 'baz'],
|
||||
)));
|
||||
self::assertEquals(3, $this->repo->countList(new ShortUrlsCountFiltering(
|
||||
tags: ['foo'],
|
||||
excludeTags: ['bar', 'baz'],
|
||||
excludeTagsMode: TagsMode::ALL,
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -349,4 +369,70 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
|
||||
excludePastValidUntil: true,
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function filteringByApiKeyNameIsPossible(): void
|
||||
{
|
||||
$apiKey1 = ApiKey::create();
|
||||
$this->getEntityManager()->persist($apiKey1);
|
||||
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
|
||||
$this->getEntityManager()->persist($apiKey2);
|
||||
$apiKey3 = ApiKey::create();
|
||||
$this->getEntityManager()->persist($apiKey3);
|
||||
|
||||
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo1',
|
||||
'apiKey' => $apiKey1,
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl1);
|
||||
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo2',
|
||||
'apiKey' => $apiKey1,
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo3',
|
||||
'apiKey' => $apiKey2,
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl3);
|
||||
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
'longUrl' => 'https://foo4',
|
||||
'apiKey' => $apiKey1,
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl4);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
// It is possible to filter by API key name when no API key or ADMIN API key is provided
|
||||
self::assertCount(3, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey1->name)));
|
||||
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey2->name)));
|
||||
self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering(apiKeyName: $apiKey3->name)));
|
||||
|
||||
self::assertCount(3, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey1,
|
||||
apiKeyName: $apiKey1->name,
|
||||
)));
|
||||
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey1,
|
||||
apiKeyName: $apiKey2->name,
|
||||
)));
|
||||
self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey1,
|
||||
apiKeyName: $apiKey3->name,
|
||||
)));
|
||||
|
||||
// When a non-admin API key is passed, it allows to filter by itself only
|
||||
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey2,
|
||||
apiKeyName: $apiKey1->name, // Ignored. Only API key 2 results are returned
|
||||
)));
|
||||
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey2,
|
||||
apiKeyName: $apiKey2->name,
|
||||
)));
|
||||
self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
|
||||
apiKey: $apiKey2,
|
||||
apiKeyName: $apiKey3->name, // Ignored. Only API key 2 results are returned
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository;
|
||||
@@ -187,13 +189,13 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
$this->createShortUrlsAndVisits(false, [$foo]);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertCount(0, $this->repo->findVisitsByTag('invalid', new VisitsListFiltering()));
|
||||
self::assertCount(18, $this->repo->findVisitsByTag($foo, new VisitsListFiltering()));
|
||||
self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(null, true)));
|
||||
self::assertCount(6, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(
|
||||
self::assertCount(0, $this->repo->findVisitsByTag('invalid', new WithDomainVisitsListFiltering()));
|
||||
self::assertCount(18, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering()));
|
||||
self::assertCount(12, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(null, true)));
|
||||
self::assertCount(6, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(
|
||||
DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
|
||||
)));
|
||||
self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(
|
||||
self::assertCount(12, $this->repo->findVisitsByTag($foo, new WithDomainVisitsListFiltering(
|
||||
DateRange::since(Chronos::parse('2016-01-03')),
|
||||
)));
|
||||
}
|
||||
@@ -203,21 +205,40 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
$foo = 'foo';
|
||||
|
||||
$this->createShortUrlsAndVisits(false, [$foo]);
|
||||
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
|
||||
ShortUrlInputFilter::TAGS => [$foo],
|
||||
ShortUrlInputFilter::DOMAIN => 'foo.com',
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl1);
|
||||
$this->createVisitsForShortUrl($shortUrl1, 6);
|
||||
|
||||
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
|
||||
ShortUrlInputFilter::TAGS => [$foo],
|
||||
]), $this->relationResolver);
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->createVisitsForShortUrl($shortUrl2, 6);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->createShortUrlsAndVisits(false, [$foo]);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(0, $this->repo->countVisitsByTag('invalid', new VisitsCountFiltering()));
|
||||
self::assertEquals(12, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering()));
|
||||
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(null, true)));
|
||||
self::assertEquals(4, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(
|
||||
self::assertEquals(0, $this->repo->countVisitsByTag('invalid', new WithDomainVisitsCountFiltering()));
|
||||
self::assertEquals(12, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering()));
|
||||
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
|
||||
excludeBots: true,
|
||||
)));
|
||||
self::assertEquals(4, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
|
||||
DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')),
|
||||
)));
|
||||
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(
|
||||
self::assertEquals(8, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
|
||||
DateRange::since(Chronos::parse('2016-01-03')),
|
||||
)));
|
||||
self::assertEquals(6, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
|
||||
domain: 'foo.com',
|
||||
)));
|
||||
self::assertEquals(6, $this->repo->countVisitsByTag($foo, new WithDomainVisitsCountFiltering(
|
||||
domain: Domain::DEFAULT_AUTHORITY,
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -318,13 +339,17 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering()));
|
||||
self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering()));
|
||||
self::assertEquals(4 + 5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering()));
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1)));
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(apiKey: $apiKey1)));
|
||||
self::assertEquals(4, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1)));
|
||||
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2)));
|
||||
self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
|
||||
apiKey: $apiKey2,
|
||||
)));
|
||||
self::assertEquals(5 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2)));
|
||||
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $domainApiKey)));
|
||||
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(
|
||||
apiKey: $domainApiKey,
|
||||
)));
|
||||
self::assertEquals(4 + 7, $this->countRepo->countNonOrphanVisits(new VisitsCountFiltering(
|
||||
apiKey: $domainApiKey,
|
||||
)));
|
||||
@@ -334,21 +359,27 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
self::assertEquals(0, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering(
|
||||
apiKey: $noOrphanVisitsApiKey,
|
||||
)));
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
|
||||
self::assertEquals(4, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
|
||||
Chronos::parse('2016-01-05')->startOfDay(),
|
||||
))));
|
||||
self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
|
||||
self::assertEquals(2, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
), false, $apiKey1)));
|
||||
self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since(
|
||||
self::assertEquals(1, $this->repo->countNonOrphanVisits(new WithDomainVisitsCountFiltering(DateRange::since(
|
||||
Chronos::parse('2016-01-07')->startOfDay(),
|
||||
), false, $apiKey2)));
|
||||
self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(
|
||||
new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
|
||||
new WithDomainVisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
|
||||
));
|
||||
self::assertEquals(3 + 5, $this->countRepo->countNonOrphanVisits(
|
||||
new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey2),
|
||||
));
|
||||
self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(
|
||||
new WithDomainVisitsCountFiltering(domain: $domain->authority),
|
||||
));
|
||||
self::assertEquals(5, $this->repo->countNonOrphanVisits(
|
||||
new WithDomainVisitsCountFiltering(domain: Domain::DEFAULT_AUTHORITY),
|
||||
));
|
||||
self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering()));
|
||||
self::assertEquals(4, $this->orphanCountRepo->countOrphanVisits(new OrphanVisitsCountFiltering()));
|
||||
self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true)));
|
||||
@@ -374,11 +405,11 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
fn () => Visit::forInvalidShortUrl(Visitor::empty()),
|
||||
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(visitedUrl: 'https://s.test/bar')),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
fn () => Visit::forRegularNotFound(Visitor::empty()),
|
||||
fn () => Visit::forRegularNotFound(Visitor::fromParams(visitedUrl: 'https://example.com/foo?1=2')),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
|
||||
@@ -417,6 +448,11 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
type: OrphanVisitType::BASE_URL,
|
||||
limit: 4,
|
||||
)));
|
||||
self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(domain: 'example.com')));
|
||||
self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(
|
||||
domain: Domain::DEFAULT_AUTHORITY,
|
||||
defaultDomain: 's.test',
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -432,11 +468,11 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
fn () => Visit::forInvalidShortUrl(Visitor::empty()),
|
||||
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(visitedUrl: 'https://s.test/foo/bar')),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
$this->getEntityManager()->persist($this->setDateOnVisit(
|
||||
fn () => Visit::forRegularNotFound(Visitor::empty()),
|
||||
fn () => Visit::forRegularNotFound(Visitor::fromParams(visitedUrl: 'https://example.com/foo/bar')),
|
||||
Chronos::parse(sprintf('2020-01-0%s', $i + 1)),
|
||||
));
|
||||
}
|
||||
@@ -465,6 +501,11 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(
|
||||
type: OrphanVisitType::REGULAR_404,
|
||||
)));
|
||||
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(domain: 'example.com')));
|
||||
self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(
|
||||
domain: Domain::DEFAULT_AUTHORITY,
|
||||
defaultDomain: 's.test',
|
||||
)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -479,31 +520,38 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering()));
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::allTime())));
|
||||
self::assertCount(4, $this->repo->findNonOrphanVisits(new VisitsListFiltering(apiKey: $authoredApiKey)));
|
||||
self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::since(
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering()));
|
||||
self::assertCount(21, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
|
||||
DateRange::allTime(),
|
||||
)));
|
||||
self::assertCount(4, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
|
||||
apiKey: $authoredApiKey,
|
||||
)));
|
||||
self::assertCount(7, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::since(
|
||||
Chronos::parse('2016-01-05')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::until(
|
||||
self::assertCount(12, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::until(
|
||||
Chronos::parse('2016-01-04')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
|
||||
self::assertCount(6, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-04')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
|
||||
self::assertCount(13, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-08')->endOfDay(),
|
||||
))));
|
||||
self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between(
|
||||
self::assertCount(3, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(DateRange::between(
|
||||
Chronos::parse('2016-01-03')->startOfDay(),
|
||||
Chronos::parse('2016-01-08')->endOfDay(),
|
||||
), limit: 10, offset: 10)));
|
||||
self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(excludeBots: true)));
|
||||
self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10)));
|
||||
self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 10, offset: 20)));
|
||||
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(limit: 5, offset: 5)));
|
||||
self::assertCount(15, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(excludeBots: true)));
|
||||
self::assertCount(10, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(limit: 10)));
|
||||
self::assertCount(1, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
)));
|
||||
self::assertCount(5, $this->repo->findNonOrphanVisits(new WithDomainVisitsListFiltering(limit: 5, offset: 5)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -526,6 +574,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
|
||||
/**
|
||||
* @return array{string, string, ShortUrl}
|
||||
* @fixme This method does too many things and is not intuitive. It should be removed or simplified
|
||||
*/
|
||||
private function createShortUrlsAndVisits(
|
||||
bool|string $withDomain = true,
|
||||
@@ -558,6 +607,10 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
return [$shortCode, $domain, $shortUrl];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $amount - How many visits in total. Defaults to 6
|
||||
* @param int $botsAmount - How many of the visits should be bots. Defaults to 2
|
||||
*/
|
||||
private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6, int $botsAmount = 2): void
|
||||
{
|
||||
for ($i = 0; $i < $amount; $i++) {
|
||||
|
||||
@@ -10,10 +10,10 @@ use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
@@ -21,13 +21,13 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
private NonOrphanVisitsPaginatorAdapter $adapter;
|
||||
private MockObject & VisitRepositoryInterface $repo;
|
||||
private VisitsParams $params;
|
||||
private WithDomainVisitsParams $params;
|
||||
private ApiKey $apiKey;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(VisitRepositoryInterface::class);
|
||||
$this->params = VisitsParams::fromRawData([]);
|
||||
$this->params = WithDomainVisitsParams::fromRawData([]);
|
||||
$this->apiKey = ApiKey::create();
|
||||
|
||||
$this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey);
|
||||
@@ -38,7 +38,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
$expectedCount = 5;
|
||||
$this->repo->expects($this->once())->method('countNonOrphanVisits')->with(
|
||||
new VisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey),
|
||||
new WithDomainVisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey),
|
||||
)->willReturn($expectedCount);
|
||||
|
||||
$result = $this->adapter->getNbResults();
|
||||
@@ -55,12 +55,12 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
$visitor = Visitor::empty();
|
||||
$list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)];
|
||||
$this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new VisitsListFiltering(
|
||||
$this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new WithDomainVisitsListFiltering(
|
||||
$this->params->dateRange,
|
||||
$this->params->excludeBots,
|
||||
$this->apiKey,
|
||||
$limit,
|
||||
$offset,
|
||||
limit: $limit,
|
||||
offset: $offset,
|
||||
))->willReturn($list);
|
||||
|
||||
$result = $this->adapter->getSlice($offset, $limit);
|
||||
|
||||
@@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
@@ -30,7 +31,12 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase
|
||||
$this->params = new OrphanVisitsParams();
|
||||
$this->apiKey = ApiKey::create();
|
||||
|
||||
$this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey);
|
||||
$this->adapter = new OrphanVisitsPaginatorAdapter(
|
||||
$this->repo,
|
||||
$this->params,
|
||||
$this->apiKey,
|
||||
new UrlShortenerOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -8,10 +8,10 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
@@ -33,7 +33,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||
$adapter = $this->createAdapter(null);
|
||||
$this->repo->expects($this->exactly($count))->method('findVisitsByTag')->with(
|
||||
'foo',
|
||||
new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset),
|
||||
new WithDomainVisitsListFiltering(DateRange::allTime(), limit: $limit, offset: $offset),
|
||||
)->willReturn([]);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
@@ -49,7 +49,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||
$adapter = $this->createAdapter($apiKey);
|
||||
$this->repo->expects($this->once())->method('countVisitsByTag')->with(
|
||||
'foo',
|
||||
new VisitsCountFiltering(DateRange::allTime(), false, $apiKey),
|
||||
new WithDomainVisitsCountFiltering(DateRange::allTime(), apiKey: $apiKey),
|
||||
)->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
@@ -59,6 +59,6 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||
|
||||
private function createAdapter(ApiKey|null $apiKey): TagVisitsPaginatorAdapter
|
||||
{
|
||||
return new TagVisitsPaginatorAdapter($this->repo, 'foo', VisitsParams::fromRawData([]), $apiKey);
|
||||
return new TagVisitsPaginatorAdapter($this->repo, 'foo', WithDomainVisitsParams::fromRawData([]), $apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProviderExternal;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
@@ -29,10 +30,12 @@ use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsCountFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Persistence\WithDomainVisitsListFiltering;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository;
|
||||
@@ -52,7 +55,7 @@ class VisitsStatsHelperTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||
$this->helper = new VisitsStatsHelper($this->em);
|
||||
$this->helper = new VisitsStatsHelper($this->em, new UrlShortenerOptions());
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideCounts')]
|
||||
@@ -147,7 +150,7 @@ class VisitsStatsHelperTest extends TestCase
|
||||
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
|
||||
$this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
$this->helper->visitsForTag($tag, new WithDomainVisitsParams(), $apiKey);
|
||||
}
|
||||
|
||||
#[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')]
|
||||
@@ -170,7 +173,7 @@ class VisitsStatsHelperTest extends TestCase
|
||||
[Visit::class, $repo2],
|
||||
]);
|
||||
|
||||
$paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey);
|
||||
$paginator = $this->helper->visitsForTag($tag, new WithDomainVisitsParams(), $apiKey);
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
}
|
||||
@@ -265,14 +268,14 @@ class VisitsStatsHelperTest extends TestCase
|
||||
);
|
||||
$repo = $this->createMock(VisitRepository::class);
|
||||
$repo->expects($this->once())->method('countNonOrphanVisits')->with(
|
||||
$this->isInstanceOf(VisitsCountFiltering::class),
|
||||
$this->isInstanceOf(WithDOmainVisitsCountFiltering::class),
|
||||
)->willReturn(count($list));
|
||||
$repo->expects($this->once())->method('findNonOrphanVisits')->with(
|
||||
$this->isInstanceOf(VisitsListFiltering::class),
|
||||
$this->isInstanceOf(WithDOmainVisitsListFiltering::class),
|
||||
)->willReturn($list);
|
||||
$this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo);
|
||||
|
||||
$paginator = $this->helper->nonOrphanVisits(new VisitsParams());
|
||||
$paginator = $this->helper->nonOrphanVisits(new WithDomainVisitsParams());
|
||||
|
||||
self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -26,9 +25,8 @@ abstract class AbstractListVisitsAction extends AbstractRestAction
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
|
||||
$visits = $this->getVisitsPaginator($request, $params, $apiKey);
|
||||
$visits = $this->getVisitsPaginator($request, $apiKey);
|
||||
|
||||
return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]);
|
||||
}
|
||||
@@ -36,9 +34,5 @@ abstract class AbstractListVisitsAction extends AbstractRestAction
|
||||
/**
|
||||
* @return Pagerfanta<Visit>
|
||||
*/
|
||||
abstract protected function getVisitsPaginator(
|
||||
ServerRequestInterface $request,
|
||||
VisitsParams $params,
|
||||
ApiKey $apiKey,
|
||||
): Pagerfanta;
|
||||
abstract protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ class DomainVisitsAction extends AbstractListVisitsAction
|
||||
parent::__construct($visitsHelper);
|
||||
}
|
||||
|
||||
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
|
||||
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
|
||||
{
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$domain = $this->resolveDomainParam($request);
|
||||
return $this->visitsHelper->visitsForDomain($domain, $params, $apiKey);
|
||||
}
|
||||
|
||||
@@ -6,18 +6,16 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class NonOrphanVisitsAction extends AbstractListVisitsAction
|
||||
{
|
||||
protected const string ROUTE_PATH = '/visits/non-orphan';
|
||||
|
||||
protected function getVisitsPaginator(
|
||||
ServerRequestInterface $request,
|
||||
VisitsParams $params,
|
||||
ApiKey $apiKey,
|
||||
): Pagerfanta {
|
||||
protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta
|
||||
{
|
||||
$params = WithDomainVisitsParams::fromRawData($request->getQueryParams());
|
||||
return $this->visitsHelper->nonOrphanVisits($params, $apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,15 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class OrphanVisitsAction extends AbstractListVisitsAction
|
||||
{
|
||||
protected const string ROUTE_PATH = '/visits/orphan';
|
||||
|
||||
protected function getVisitsPaginator(
|
||||
ServerRequestInterface $request,
|
||||
VisitsParams $params,
|
||||
ApiKey $apiKey,
|
||||
): Pagerfanta {
|
||||
$orphanParams = OrphanVisitsParams::fromVisitsParamsAndRawData($params, $request->getQueryParams());
|
||||
protected function getVisitsPaginator(ServerRequestInterface $request, ApiKey $apiKey): Pagerfanta
|
||||
{
|
||||
$orphanParams = OrphanVisitsParams::fromRawData($request->getQueryParams());
|
||||
return $this->visitsHelper->orphanVisits($orphanParams, $apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ class ShortUrlVisitsAction extends AbstractListVisitsAction
|
||||
{
|
||||
protected const string ROUTE_PATH = '/short-urls/{shortCode}/visits';
|
||||
|
||||
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
|
||||
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
|
||||
{
|
||||
$params = VisitsParams::fromRawData($request->getQueryParams());
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
return $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey);
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
|
||||
|
||||
use Pagerfanta\Pagerfanta;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class TagVisitsAction extends AbstractListVisitsAction
|
||||
{
|
||||
protected const string ROUTE_PATH = '/tags/{tag}/visits';
|
||||
|
||||
protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta
|
||||
protected function getVisitsPaginator(Request $request, ApiKey $apiKey): Pagerfanta
|
||||
{
|
||||
$params = WithDomainVisitsParams::fromRawData($request->getQueryParams());
|
||||
$tag = $request->getAttribute('tag', '');
|
||||
return $this->visitsHelper->visitsForTag($tag, $params, $apiKey);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Rest\ApiKey\Repository;
|
||||
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
@@ -48,10 +49,9 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('a.id')
|
||||
->from(ApiKey::class, 'a')
|
||||
->where($qb->expr()->eq('a.name', ':name'))
|
||||
->setParameter('name', $name)
|
||||
->setMaxResults(1);
|
||||
->from(ApiKey::class, 'a');
|
||||
|
||||
$this->queryBuilderByName($qb, $name);
|
||||
|
||||
// Lock for update, to avoid a race condition that inserts a duplicate name after we have checked if one existed
|
||||
$query = $qb->getQuery();
|
||||
@@ -59,4 +59,27 @@ class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRe
|
||||
|
||||
return $query->getOneOrNullResult() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function deleteByName(string $name): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->delete(ApiKey::class, 'a');
|
||||
|
||||
$this->queryBuilderByName($qb, $name);
|
||||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a condition by name to a query builder, and ensure only one result is returned
|
||||
*/
|
||||
private function queryBuilderByName(QueryBuilder $qb, string $name): void
|
||||
{
|
||||
$qb->where($qb->expr()->eq('a.name', ':name'))
|
||||
->setParameter('name', $name)
|
||||
->setMaxResults(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,10 @@ interface ApiKeyRepositoryInterface extends EntityRepositoryInterface, EntitySpe
|
||||
* Checks whether an API key with provided name exists or not
|
||||
*/
|
||||
public function nameExists(string $name): bool;
|
||||
|
||||
/**
|
||||
* Delete an API key by name
|
||||
* @return positive-int|0 Number of affected results
|
||||
*/
|
||||
public function deleteByName(string $name): int;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,10 @@ readonly class CrossDomainMiddleware implements MiddlewareInterface, RequestMeth
|
||||
private function addOptionsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
// Options requests should always be empty and have a 204 status code
|
||||
return EmptyResponse::withHeaders([
|
||||
...$response->getHeaders(),
|
||||
'Access-Control-Allow-Methods' => $this->resolveCorsAllowedMethods($response),
|
||||
'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'),
|
||||
'Access-Control-Max-Age' => $this->options->maxAge,
|
||||
]);
|
||||
return EmptyResponse::withHeaders($response->getHeaders())
|
||||
->withHeader('Access-Control-Allow-Methods', $this->resolveCorsAllowedMethods($response))
|
||||
->withHeader('Access-Control-Allow-Headers', $request->getHeaderLine('Access-Control-Request-Headers'))
|
||||
->withHeader('Access-Control-Max-Age', (string) $this->options->maxAge);
|
||||
}
|
||||
|
||||
private function resolveCorsAllowedMethods(ResponseInterface $response): string
|
||||
|
||||
@@ -68,6 +68,17 @@ readonly class ApiKeyService implements ApiKeyServiceInterface
|
||||
return new ApiKeyCheckResult($apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function deleteByName(string $apiKeyName): void
|
||||
{
|
||||
$affectedResults = $this->repo->deleteByName($apiKeyName);
|
||||
if ($affectedResults === 0) {
|
||||
throw ApiKeyNotFoundException::forName($apiKeyName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,11 @@ interface ApiKeyServiceInterface
|
||||
|
||||
public function check(string $key): ApiKeyCheckResult;
|
||||
|
||||
/**
|
||||
* @throws ApiKeyNotFoundException
|
||||
*/
|
||||
public function deleteByName(string $apiKeyName): void;
|
||||
|
||||
/**
|
||||
* @throws ApiKeyNotFoundException
|
||||
*/
|
||||
|
||||
@@ -153,8 +153,11 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
];
|
||||
|
||||
#[Test, DataProvider('provideFilteredLists')]
|
||||
public function shortUrlsAreProperlyListed(array $query, array $expectedShortUrls, string $apiKey): void
|
||||
{
|
||||
public function shortUrlsAreProperlyListed(
|
||||
array $query,
|
||||
array $expectedShortUrls,
|
||||
string $apiKey = 'valid_api_key',
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query], $apiKey);
|
||||
$respPayload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
@@ -176,21 +179,21 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['excludePastValidUntil' => 'true'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['excludeMaxVisitsReached' => 'true'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'shortCode'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
@@ -198,7 +201,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'shortCode-DESC'], [
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
@@ -206,7 +209,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['orderBy' => 'title-DESC'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_META,
|
||||
@@ -214,66 +217,105 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['bar']], [
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar', 'baz']], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key'];
|
||||
]];
|
||||
yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], []];
|
||||
yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'alejandro'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'cool'], [
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['searchTerm' => 'example.com'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['domain' => 'example.com'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
yield [['domain' => Domain::DEFAULT_AUTHORITY], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
], 'valid_api_key'];
|
||||
]];
|
||||
|
||||
// Exclude tags
|
||||
yield [['excludeTags' => ['foo']], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
yield [['excludeTags' => ['foo', 'bar']], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
yield [['excludeTags' => ['bar', 'foo'], 'excludeTagsMode' => 'all'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
self::SHORT_URL_DOCS,
|
||||
]];
|
||||
|
||||
// Filter by API key name
|
||||
yield [['apiKeyName' => 'author_api_key'], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
]];
|
||||
yield [['apiKeyName' => 'invalid'], []];
|
||||
yield [['apiKeyName' => 'valid_api_key'], [
|
||||
// If the author_api_key is used, the `apiKeyName` param is ignored
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_SHLINK_WITH_TITLE,
|
||||
], 'author_api_key'];
|
||||
yield [['apiKeyName' => 'valid_api_key'], [
|
||||
// If the domain_api_key is used, the `apiKeyName` param is ignored
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
], 'domain_api_key'];
|
||||
|
||||
// Different API keys
|
||||
yield [[], [
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_META,
|
||||
|
||||
@@ -31,5 +31,7 @@ class NonOrphanVisitsTest extends ApiTestCase
|
||||
yield 'bots excluded' => [['excludeBots' => 'true'], 6, 6];
|
||||
yield 'bots excluded and pagination' => [['excludeBots' => 'true', 'page' => 1, 'itemsPerPage' => 4], 6, 4];
|
||||
yield 'date filter' => [['startDate' => Chronos::now()->addDays(1)->toAtomString()], 0, 0];
|
||||
yield 'domain filter' => [['domain' => 'example.com'], 0, 0];
|
||||
yield 'default domain filter' => [['domain' => 'DEFAULT'], 7, 7];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class OrphanVisitsTest extends ApiTestCase
|
||||
'userAgent' => 'cf-facebook',
|
||||
'visitLocation' => null,
|
||||
'potentialBot' => true,
|
||||
'visitedUrl' => 'foo.com',
|
||||
'visitedUrl' => 'https://example.com/short',
|
||||
'type' => 'invalid_short_url',
|
||||
'redirectUrl' => null,
|
||||
];
|
||||
@@ -29,7 +29,7 @@ class OrphanVisitsTest extends ApiTestCase
|
||||
'userAgent' => 'shlink-tests-agent',
|
||||
'visitLocation' => null,
|
||||
'potentialBot' => false,
|
||||
'visitedUrl' => '',
|
||||
'visitedUrl' => 'https://s.test/bar',
|
||||
'type' => 'regular_404',
|
||||
'redirectUrl' => null,
|
||||
];
|
||||
@@ -39,7 +39,7 @@ class OrphanVisitsTest extends ApiTestCase
|
||||
'userAgent' => 'shlink-tests-agent',
|
||||
'visitLocation' => null,
|
||||
'potentialBot' => false,
|
||||
'visitedUrl' => '',
|
||||
'visitedUrl' => 'https://s.test/foo',
|
||||
'type' => 'base_url',
|
||||
'redirectUrl' => null,
|
||||
];
|
||||
@@ -80,6 +80,14 @@ class OrphanVisitsTest extends ApiTestCase
|
||||
1,
|
||||
[self::INVALID_SHORT_URL],
|
||||
];
|
||||
yield 'example domain only' => [['domain' => 'example.com'], 1, 1, [self::INVALID_SHORT_URL]];
|
||||
yield 'default domain only' => [['domain' => 's.test'], 2, 2, [self::REGULAR_NOT_FOUND, self::BASE_URL]];
|
||||
yield 'default domain only with DEFAULT keyword' => [
|
||||
['domain' => 'DEFAULT'],
|
||||
2,
|
||||
2,
|
||||
[self::REGULAR_NOT_FOUND, self::BASE_URL],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -17,11 +17,11 @@ class TagVisitsTest extends ApiTestCase
|
||||
public function expectedVisitsAreReturned(
|
||||
string $apiKey,
|
||||
string $tag,
|
||||
bool $excludeBots,
|
||||
array $query,
|
||||
int $expectedVisitsAmount,
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [
|
||||
RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [],
|
||||
RequestOptions::QUERY => $query,
|
||||
], $apiKey);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
@@ -33,16 +33,18 @@ class TagVisitsTest extends ApiTestCase
|
||||
|
||||
public static function provideTags(): iterable
|
||||
{
|
||||
yield 'foo with admin API key' => ['valid_api_key', 'foo', false, 5];
|
||||
yield 'foo with admin API key and no bots' => ['valid_api_key', 'foo', true, 4];
|
||||
yield 'bar with admin API key' => ['valid_api_key', 'bar', false, 2];
|
||||
yield 'bar with admin API key and no bots' => ['valid_api_key', 'bar', true, 1];
|
||||
yield 'baz with admin API key' => ['valid_api_key', 'baz', false, 0];
|
||||
yield 'foo with author API key' => ['author_api_key', 'foo', false, 5];
|
||||
yield 'foo with author API key and no bots' => ['author_api_key', 'foo', true, 4];
|
||||
yield 'bar with author API key' => ['author_api_key', 'bar', false, 2];
|
||||
yield 'bar with author API key and no bots' => ['author_api_key', 'bar', true, 1];
|
||||
yield 'foo with domain API key' => ['domain_api_key', 'foo', false, 0];
|
||||
yield 'foo with admin API key' => ['valid_api_key', 'foo', [], 5];
|
||||
yield 'foo with admin API key and no bots' => ['valid_api_key', 'foo', ['excludeBots' => true], 4];
|
||||
yield 'bar with admin API key' => ['valid_api_key', 'bar', [], 2];
|
||||
yield 'bar with admin API key and no bots' => ['valid_api_key', 'bar', ['excludeBots' => true], 1];
|
||||
yield 'baz with admin API key' => ['valid_api_key', 'baz', [], 0];
|
||||
yield 'foo with author API key' => ['author_api_key', 'foo', [], 5];
|
||||
yield 'foo with author API key and no bots' => ['author_api_key', 'foo', ['excludeBots' => true], 4];
|
||||
yield 'bar with author API key' => ['author_api_key', 'bar', [], 2];
|
||||
yield 'bar with author API key and no bots' => ['author_api_key', 'bar', ['excludeBots' => true], 1];
|
||||
yield 'foo with domain API key' => ['domain_api_key', 'foo', [], 0];
|
||||
yield 'foo with specific domain' => ['valid_api_key', 'foo', ['domain' => 'example.com'], 0];
|
||||
yield 'foo with default domain' => ['valid_api_key', 'foo', ['domain' => 'DEFAULT'], 5];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideApiKeysAndTags')]
|
||||
|
||||
@@ -98,7 +98,6 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
|
||||
{
|
||||
$ref = new ReflectionObject($shortUrl);
|
||||
$dateProp = $ref->getProperty('dateCreated');
|
||||
$dateProp->setAccessible(true);
|
||||
$dateProp->setValue($shortUrl, Chronos::parse($date));
|
||||
|
||||
return $shortUrl;
|
||||
|
||||
@@ -58,20 +58,32 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
|
||||
Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''),
|
||||
));
|
||||
|
||||
// Orphan visits (s.test is the default domain in tests env)
|
||||
$manager->persist($this->setVisitDate(
|
||||
fn () => Visit::forBasePath(Visitor::fromParams('shlink-tests-agent', 'https://s.test', '1.2.3.4')),
|
||||
fn () => Visit::forBasePath(Visitor::fromParams(
|
||||
'shlink-tests-agent',
|
||||
'https://s.test',
|
||||
'1.2.3.4',
|
||||
visitedUrl: 'https://s.test/foo',
|
||||
)),
|
||||
'2020-01-01',
|
||||
));
|
||||
$manager->persist($this->setVisitDate(
|
||||
fn () => Visit::forRegularNotFound(
|
||||
Visitor::fromParams('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4'),
|
||||
),
|
||||
fn () => Visit::forRegularNotFound(Visitor::fromParams(
|
||||
'shlink-tests-agent',
|
||||
'https://s.test/foo/bar',
|
||||
'1.2.3.4',
|
||||
visitedUrl: 'https://s.test/bar',
|
||||
)),
|
||||
'2020-02-01',
|
||||
));
|
||||
$manager->persist($this->setVisitDate(
|
||||
fn () => Visit::forInvalidShortUrl(
|
||||
Visitor::fromParams('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com'),
|
||||
),
|
||||
fn () => Visit::forInvalidShortUrl(Visitor::fromParams(
|
||||
'cf-facebook',
|
||||
'https://s.test/foo',
|
||||
'1.2.3.4',
|
||||
visitedUrl: 'https://example.com/short',
|
||||
)),
|
||||
'2020-03-01',
|
||||
));
|
||||
|
||||
|
||||
@@ -40,4 +40,18 @@ class ApiKeyRepositoryTest extends DatabaseTestCase
|
||||
self::assertTrue($this->repo->nameExists('foo'));
|
||||
self::assertFalse($this->repo->nameExists('bar'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function deleteByNameReturnsExpectedValue(): void
|
||||
{
|
||||
$this->getEntityManager()->persist(ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')));
|
||||
$this->getEntityManager()->flush();
|
||||
$this->getEntityManager()->clear();
|
||||
|
||||
self::assertEquals(0, $this->repo->deleteByName('invalid'));
|
||||
self::assertEquals(1, $this->repo->deleteByName('foo'));
|
||||
|
||||
// Verify the API key has been deleted
|
||||
self::assertNull($this->repo->findOneBy(['name' => 'foo']));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase
|
||||
|
||||
$ref = new ReflectionObject($instance);
|
||||
$prop = $ref->getProperty('responseFactory');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
/** @var ResponseFactoryInterface $value */
|
||||
$value = $prop->getValue($instance);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
@@ -268,4 +269,19 @@ class ApiKeyServiceTest extends TestCase
|
||||
self::assertSame($apiKey, $result);
|
||||
self::assertEquals('new', $apiKey->name);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([0, true])]
|
||||
#[TestWith([1, false])]
|
||||
public function deleteByNameThrowsIfNoResultsAreAffected(int $affectedResults, bool $shouldThrow): void
|
||||
{
|
||||
$name = 'some_name';
|
||||
$this->repo->expects($this->once())->method('deleteByName')->with($name)->willReturn($affectedResults);
|
||||
|
||||
if ($shouldThrow) {
|
||||
$this->expectException(ApiKeyNotFoundException::class);
|
||||
}
|
||||
|
||||
$this->service->deleteByName($name);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user