mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-29 04:52:54 +03:00
Merge pull request #2086 from acelaya-forks/feature/delete-expired
Feature/delete expired
This commit is contained in:
commit
cc134abd12
16 changed files with 458 additions and 11 deletions
4
.github/workflows/ci-db-tests.yml
vendored
4
.github/workflows/ci-db-tests.yml
vendored
|
@ -23,7 +23,7 @@ jobs:
|
||||||
run: sudo ./data/infra/ci/install-ms-odbc.sh
|
run: sudo ./data/infra/ci/install-ms-odbc.sh
|
||||||
- name: Start database server
|
- name: Start database server
|
||||||
if: ${{ inputs.platform != 'sqlite:ci' }}
|
if: ${{ inputs.platform != 'sqlite:ci' }}
|
||||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }}
|
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_${{ inputs.platform }}
|
||||||
- uses: './.github/actions/ci-setup'
|
- uses: './.github/actions/ci-setup'
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
|
@ -31,7 +31,7 @@ jobs:
|
||||||
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
|
||||||
- name: Create test database
|
- name: Create test database
|
||||||
if: ${{ inputs.platform == 'ms' }}
|
if: ${{ inputs.platform == 'ms' }}
|
||||||
run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: composer test:db:${{ inputs.platform }}
|
run: composer test:db:${{ inputs.platform }}
|
||||||
- name: Upload code coverage
|
- name: Upload code coverage
|
||||||
|
|
4
.github/workflows/ci-tests.yml
vendored
4
.github/workflows/ci-tests.yml
vendored
|
@ -20,10 +20,10 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Start postgres database server
|
- name: Start postgres database server
|
||||||
if: ${{ inputs.test-group == 'api' }}
|
if: ${{ inputs.test-group == 'api' }}
|
||||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
|
||||||
- name: Start maria database server
|
- name: Start maria database server
|
||||||
if: ${{ inputs.test-group == 'cli' }}
|
if: ${{ inputs.test-group == 'cli' }}
|
||||||
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
|
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_maria
|
||||||
- uses: './.github/actions/ci-setup'
|
- uses: './.github/actions/ci-setup'
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php-version }}
|
php-version: ${{ matrix.php-version }}
|
||||||
|
|
|
@ -11,6 +11,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||||
Previously, this was exposed only for orphan visits, since this can be an arbitrary value for those.
|
Previously, this was exposed only for orphan visits, since this can be an arbitrary value for those.
|
||||||
|
|
||||||
* [#2077](https://github.com/shlinkio/shlink/issues/2077) When sending visits to Matomo, the short URL title is now used as document title in matomo.
|
* [#2077](https://github.com/shlinkio/shlink/issues/2077) When sending visits to Matomo, the short URL title is now used as document title in matomo.
|
||||||
|
* [#2059](https://github.com/shlinkio/shlink/issues/2059) Add new `short-url:delete-expired` command that can be used to programmatically delete expired short URLs.
|
||||||
|
|
||||||
|
Expired short URLs are those that have a `validUntil` date in the past, or optionally, that have reached the max amount of visits.
|
||||||
|
|
||||||
|
This command can be run periodically by those who create many disposable URLs which are valid only for a period of time, and then can be deleted to save space.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible.
|
* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible.
|
||||||
|
|
|
@ -14,6 +14,8 @@ return [
|
||||||
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
|
||||||
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
|
||||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class,
|
Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class,
|
||||||
|
Command\ShortUrl\DeleteExpiredShortUrlsCommand::NAME =>
|
||||||
|
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class,
|
||||||
|
|
||||||
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
|
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
|
||||||
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
|
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
|
||||||
|
|
|
@ -45,6 +45,7 @@ return [
|
||||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
|
||||||
|
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
|
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
|
||||||
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
|
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
|
||||||
|
@ -96,6 +97,7 @@ return [
|
||||||
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
|
||||||
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
||||||
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
|
||||||
|
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class],
|
||||||
|
|
||||||
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
|
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
|
||||||
Command\Visit\LocateVisitsCommand::class => [
|
Command\Visit\LocateVisitsCommand::class => [
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class DeleteExpiredShortUrlsCommand extends Command
|
||||||
|
{
|
||||||
|
public const NAME = 'short-url:delete-expired';
|
||||||
|
|
||||||
|
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||||
|
{
|
||||||
|
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'));
|
||||||
|
|
||||||
|
if (! $force && ! $dryRun) {
|
||||||
|
$io->warning([
|
||||||
|
'Careful!',
|
||||||
|
'You are about to perform a destructive operation that can result in deleted short URLs and visits.',
|
||||||
|
'This action cannot be undone. Proceed at your own risk',
|
||||||
|
]);
|
||||||
|
if (! $io->confirm('Continue?', default: false)) {
|
||||||
|
return ExitCode::EXIT_WARNING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$result = $this->deleteShortUrlService->countExpiredShortUrls($conditions);
|
||||||
|
$io->success(sprintf('There are %s expired short URLs matching provided conditions', $result));
|
||||||
|
return ExitCode::EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
|
||||||
|
$io->success(sprintf('%s expired short URLs have been deleted', $result));
|
||||||
|
return ExitCode::EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteExpiredShortUrlsCommand;
|
||||||
|
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
|
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
class DeleteExpiredShortUrlsCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private CommandTester $commandTester;
|
||||||
|
private MockObject & DeleteShortUrlServiceInterface $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service = $this->createMock(DeleteShortUrlServiceInterface::class);
|
||||||
|
$this->commandTester = CliTestUtils::testerForCommand(new DeleteExpiredShortUrlsCommand($this->service));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function warningIsDisplayedAndExecutionCanBeCancelled(): void
|
||||||
|
{
|
||||||
|
$this->service->expects($this->never())->method('countExpiredShortUrls');
|
||||||
|
$this->service->expects($this->never())->method('deleteExpiredShortUrls');
|
||||||
|
|
||||||
|
$this->commandTester->setInputs(['n']);
|
||||||
|
$this->commandTester->execute([]);
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
$status = $this->commandTester->getStatusCode();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Careful!', $output);
|
||||||
|
self::assertEquals(ExitCode::EXIT_WARNING, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([[], [], true])]
|
||||||
|
#[TestWith([['--force' => true], [], false])]
|
||||||
|
#[TestWith([['-f' => true], [], false])]
|
||||||
|
#[TestWith([[], ['interactive' => false], false])]
|
||||||
|
public function deletionIsExecutedByDefault(array $input, array $options, bool $expectsWarning): void
|
||||||
|
{
|
||||||
|
$this->service->expects($this->never())->method('countExpiredShortUrls');
|
||||||
|
$this->service->expects($this->once())->method('deleteExpiredShortUrls')->willReturn(5);
|
||||||
|
|
||||||
|
$this->commandTester->setInputs(['y']);
|
||||||
|
$this->commandTester->execute($input, $options);
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
$status = $this->commandTester->getStatusCode();
|
||||||
|
|
||||||
|
if ($expectsWarning) {
|
||||||
|
self::assertStringContainsString('Careful!', $output);
|
||||||
|
} else {
|
||||||
|
self::assertStringNotContainsString('Careful!', $output);
|
||||||
|
}
|
||||||
|
self::assertStringContainsString('5 expired short URLs have been deleted', $output);
|
||||||
|
self::assertEquals(ExitCode::EXIT_SUCCESS, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function countIsExecutedDuringDryRun(): void
|
||||||
|
{
|
||||||
|
$this->service->expects($this->once())->method('countExpiredShortUrls')->willReturn(38);
|
||||||
|
$this->service->expects($this->never())->method('deleteExpiredShortUrls');
|
||||||
|
|
||||||
|
$this->commandTester->execute(['--dry-run' => true]);
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
$status = $this->commandTester->getStatusCode();
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('Careful!', $output);
|
||||||
|
self::assertStringContainsString('There are 38 expired short URLs matching provided conditions', $output);
|
||||||
|
self::assertEquals(ExitCode::EXIT_SUCCESS, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([[], new ExpiredShortUrlsConditions()])]
|
||||||
|
#[TestWith([['--evaluate-max-visits' => true], new ExpiredShortUrlsConditions(maxVisitsReached: true)])]
|
||||||
|
public function providesExpectedConditionsToService(array $extraInput, ExpiredShortUrlsConditions $conditions): void
|
||||||
|
{
|
||||||
|
$this->service->expects($this->once())->method('countExpiredShortUrls')->with($conditions)->willReturn(4);
|
||||||
|
$this->commandTester->execute(['--dry-run' => true, ...$extraInput]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -57,6 +57,10 @@ return [
|
||||||
EntityRepositoryFactory::class,
|
EntityRepositoryFactory::class,
|
||||||
ShortUrl\Entity\ShortUrl::class,
|
ShortUrl\Entity\ShortUrl::class,
|
||||||
],
|
],
|
||||||
|
ShortUrl\Repository\ExpiredShortUrlsRepository::class => [
|
||||||
|
EntityRepositoryFactory::class,
|
||||||
|
ShortUrl\Entity\ShortUrl::class,
|
||||||
|
],
|
||||||
|
|
||||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
|
@ -147,6 +151,7 @@ return [
|
||||||
'em',
|
'em',
|
||||||
Options\DeleteShortUrlsOptions::class,
|
Options\DeleteShortUrlsOptions::class,
|
||||||
ShortUrl\ShortUrlResolver::class,
|
ShortUrl\ShortUrlResolver::class,
|
||||||
|
ShortUrl\Repository\ExpiredShortUrlsRepository::class,
|
||||||
],
|
],
|
||||||
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
|
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
|
||||||
ShortUrl\ShortUrlVisitsDeleter::class => [
|
ShortUrl\ShortUrlVisitsDeleter::class => [
|
||||||
|
|
|
@ -8,15 +8,18 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepositoryInterface;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
private readonly DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
||||||
private readonly ShortUrlResolverInterface $urlResolver,
|
private ShortUrlResolverInterface $urlResolver,
|
||||||
|
private ExpiredShortUrlsRepositoryInterface $expiredShortUrlsRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,4 +50,14 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||||
$this->deleteShortUrlsOptions->visitsThreshold,
|
$this->deleteShortUrlsOptions->visitsThreshold,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int
|
||||||
|
{
|
||||||
|
return $this->expiredShortUrlsRepository->delete($conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int
|
||||||
|
{
|
||||||
|
return $this->expiredShortUrlsRepository->dryCount($conditions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\Core\ShortUrl;
|
namespace Shlinkio\Shlink\Core\ShortUrl;
|
||||||
|
|
||||||
use Shlinkio\Shlink\Core\Exception;
|
use Shlinkio\Shlink\Core\Exception;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||||
|
|
||||||
|
@ -19,4 +20,14 @@ interface DeleteShortUrlServiceInterface
|
||||||
bool $ignoreThreshold = false,
|
bool $ignoreThreshold = false,
|
||||||
?ApiKey $apiKey = null,
|
?ApiKey $apiKey = null,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes short URLs that are considered expired based on provided conditions
|
||||||
|
*/
|
||||||
|
public function deleteExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts short URLs that are considered expired based on provided conditions, without really deleting them
|
||||||
|
*/
|
||||||
|
public function countExpiredShortUrls(ExpiredShortUrlsConditions $conditions): int;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
||||||
|
|
||||||
|
final readonly class ExpiredShortUrlsConditions
|
||||||
|
{
|
||||||
|
public function __construct(public bool $pastValidUntil = true, public bool $maxVisitsReached = false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasConditions(): bool
|
||||||
|
{
|
||||||
|
return $this->pastValidUntil || $this->maxVisitsReached;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
class ExpiredShortUrlsRepository extends EntitySpecificationRepository implements ExpiredShortUrlsRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function delete(ExpiredShortUrlsConditions $conditions): int
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb->delete(ShortUrl::class, 's');
|
||||||
|
|
||||||
|
return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->execute());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function dryCount(ExpiredShortUrlsConditions $conditions): int
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb->select('COUNT(s.id)')
|
||||||
|
->from(ShortUrl::class, 's');
|
||||||
|
|
||||||
|
return $this->applyConditions($qb, $conditions, fn () => (int) $qb->getQuery()->getSingleScalarResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(): int $getResultFromQueryBuilder
|
||||||
|
*/
|
||||||
|
private function applyConditions(
|
||||||
|
QueryBuilder $qb,
|
||||||
|
ExpiredShortUrlsConditions $conditions,
|
||||||
|
callable $getResultFromQueryBuilder,
|
||||||
|
): int {
|
||||||
|
if (! $conditions->hasConditions()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conditions->pastValidUntil) {
|
||||||
|
$qb
|
||||||
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->isNotNull('s.validUntil'),
|
||||||
|
$qb->expr()->lt('s.validUntil', ':now'),
|
||||||
|
))
|
||||||
|
->setParameter('now', Chronos::now()->toDateTimeString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($conditions->maxVisitsReached) {
|
||||||
|
$qb->orWhere($qb->expr()->andX(
|
||||||
|
$qb->expr()->isNotNull('s.maxVisits'),
|
||||||
|
$qb->expr()->lte(
|
||||||
|
's.maxVisits',
|
||||||
|
sprintf(
|
||||||
|
'(SELECT COALESCE(SUM(vc.count), 0) FROM %s as vc WHERE vc.shortUrl=s)',
|
||||||
|
ShortUrlVisitsCount::class,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $getResultFromQueryBuilder();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\ShortUrl\Repository;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
|
|
||||||
|
interface ExpiredShortUrlsRepositoryInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Delete expired short URLs matching provided conditions
|
||||||
|
*/
|
||||||
|
public function delete(ExpiredShortUrlsConditions $conditions): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count how many expired short URLs would be deleted for provided conditions
|
||||||
|
*/
|
||||||
|
public function dryCount(ExpiredShortUrlsConditions $conditions): int;
|
||||||
|
}
|
|
@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
|
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
|
||||||
|
|
||||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
@ -147,7 +146,10 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
|
||||||
$qb->expr()->isNull('s.maxVisits'),
|
$qb->expr()->isNull('s.maxVisits'),
|
||||||
$qb->expr()->gt(
|
$qb->expr()->gt(
|
||||||
's.maxVisits',
|
's.maxVisits',
|
||||||
sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class),
|
sprintf(
|
||||||
|
'(SELECT COALESCE(SUM(vc.count), 0) FROM %s as vc WHERE vc.shortUrl=s)',
|
||||||
|
ShortUrlVisitsCount::class,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepository;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
|
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||||
|
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||||
|
|
||||||
|
class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase
|
||||||
|
{
|
||||||
|
private ExpiredShortUrlsRepository $repository;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$em = $this->getEntityManager();
|
||||||
|
$this->repository = new ExpiredShortUrlsRepository($em, $em->getClassMetadata(ShortUrl::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])]
|
||||||
|
public function deletesExpectedAmountOfShortUrls(
|
||||||
|
ExpiredShortUrlsConditions $conditions,
|
||||||
|
int $expectedDeletedShortUrls,
|
||||||
|
): void {
|
||||||
|
$createdShortUrls = $this->createDataSet();
|
||||||
|
|
||||||
|
self::assertEquals($expectedDeletedShortUrls, $this->repository->delete($conditions));
|
||||||
|
self::assertEquals(
|
||||||
|
$createdShortUrls - $expectedDeletedShortUrls,
|
||||||
|
$this->getEntityManager()->getRepository(ShortUrl::class)->count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: false), 0])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: false), 7])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: false, maxVisitsReached: true), 6])]
|
||||||
|
#[TestWith([new ExpiredShortUrlsConditions(pastValidUntil: true, maxVisitsReached: true), 9])]
|
||||||
|
public function countsExpectedAmountOfShortUrls(
|
||||||
|
ExpiredShortUrlsConditions $conditions,
|
||||||
|
int $expectedShortUrlsCount,
|
||||||
|
): void {
|
||||||
|
$createdShortUrls = $this->createDataSet();
|
||||||
|
|
||||||
|
self::assertEquals($expectedShortUrlsCount, $this->repository->dryCount($conditions));
|
||||||
|
self::assertEquals($createdShortUrls, $this->getEntityManager()->getRepository(ShortUrl::class)->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDataSet(): int
|
||||||
|
{
|
||||||
|
// Create some non-expired short URLs
|
||||||
|
$this->createShortUrls(5);
|
||||||
|
$this->createShortUrls(2, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->addDays(1)->toAtomString()]);
|
||||||
|
$this->createShortUrls(3, [ShortUrlInputFilter::MAX_VISITS => 4], visitsPerShortUrl: 2);
|
||||||
|
|
||||||
|
// Create some short URLs with a valid date in the past
|
||||||
|
$this->createShortUrls(3, [ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString()]);
|
||||||
|
|
||||||
|
// Create some short URLs which reached the max amount of visits
|
||||||
|
$this->createShortUrls(2, [ShortUrlInputFilter::MAX_VISITS => 3], visitsPerShortUrl: 3);
|
||||||
|
|
||||||
|
// Create some short URLs with a valid date in the past which also reached the max amount of visits
|
||||||
|
$this->createShortUrls(4, [
|
||||||
|
ShortUrlInputFilter::VALID_UNTIL => Chronos::now()->subDays(1)->toAtomString(),
|
||||||
|
ShortUrlInputFilter::MAX_VISITS => 3,
|
||||||
|
], visitsPerShortUrl: 4);
|
||||||
|
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
|
return 5 + 2 + 3 + 3 + 2 + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createShortUrls(int $amountOfShortUrls, array $metadata = [], int $visitsPerShortUrl = 0): void
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < $amountOfShortUrls; $i++) {
|
||||||
|
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||||
|
ShortUrlInputFilter::LONG_URL => 'https://shlink.io',
|
||||||
|
...$metadata,
|
||||||
|
]));
|
||||||
|
$this->getEntityManager()->persist($shortUrl);
|
||||||
|
|
||||||
|
for ($j = 0; $j < $visitsPerShortUrl; $j++) {
|
||||||
|
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
|
||||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService;
|
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Repository\ExpiredShortUrlsRepository;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||||
|
@ -26,6 +28,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
private MockObject & EntityManagerInterface $em;
|
private MockObject & EntityManagerInterface $em;
|
||||||
private MockObject & ShortUrlResolverInterface $urlResolver;
|
private MockObject & ShortUrlResolverInterface $urlResolver;
|
||||||
|
private MockObject & ExpiredShortUrlsRepository $expiredShortUrlsRepository;
|
||||||
private string $shortCode;
|
private string $shortCode;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
|
@ -39,6 +42,8 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||||
|
|
||||||
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
|
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
|
||||||
$this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl);
|
$this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl);
|
||||||
|
|
||||||
|
$this->expiredShortUrlsRepository = $this->createMock(ExpiredShortUrlsRepository::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
@ -94,11 +99,33 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||||
$service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode));
|
$service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function deleteExpiredShortUrlsDelegatesToRepository(): void
|
||||||
|
{
|
||||||
|
$conditions = new ExpiredShortUrlsConditions();
|
||||||
|
$this->expiredShortUrlsRepository->expects($this->once())->method('delete')->with($conditions)->willReturn(5);
|
||||||
|
|
||||||
|
$result = $this->createService()->deleteExpiredShortUrls($conditions);
|
||||||
|
|
||||||
|
self::assertEquals(5, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function countExpiredShortUrlsDelegatesToRepository(): void
|
||||||
|
{
|
||||||
|
$conditions = new ExpiredShortUrlsConditions();
|
||||||
|
$this->expiredShortUrlsRepository->expects($this->once())->method('dryCount')->with($conditions)->willReturn(2);
|
||||||
|
|
||||||
|
$result = $this->createService()->countExpiredShortUrls($conditions);
|
||||||
|
|
||||||
|
self::assertEquals(2, $result);
|
||||||
|
}
|
||||||
|
|
||||||
private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService
|
private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService
|
||||||
{
|
{
|
||||||
return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions(
|
return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions(
|
||||||
$visitsThreshold,
|
$visitsThreshold,
|
||||||
$checkVisitsThreshold,
|
$checkVisitsThreshold,
|
||||||
), $this->urlResolver);
|
), $this->urlResolver, $this->expiredShortUrlsRepository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue