Merge pull request #2086 from acelaya-forks/feature/delete-expired

Feature/delete expired
This commit is contained in:
Alejandro Celaya 2024-04-03 19:27:17 +02:00 committed by GitHub
commit cc134abd12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 458 additions and 11 deletions

View file

@ -23,7 +23,7 @@ jobs:
run: sudo ./data/infra/ci/install-ms-odbc.sh
- name: Start database server
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'
with:
php-version: ${{ matrix.php-version }}
@ -31,7 +31,7 @@ jobs:
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
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
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage

View file

@ -20,10 +20,10 @@ jobs:
- uses: actions/checkout@v4
- name: Start postgres database server
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
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'
with:
php-version: ${{ matrix.php-version }}

View file

@ -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.
* [#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
* [#2034](https://github.com/shlinkio/shlink/issues/2034) Modernize entities, using constructor property promotion and readonly wherever possible.

View file

@ -14,6 +14,8 @@ return [
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class,
Command\ShortUrl\DeleteExpiredShortUrlsCommand::NAME =>
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,

View file

@ -45,6 +45,7 @@ return [
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
@ -96,6 +97,7 @@ return [
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
Command\ShortUrl\DeleteExpiredShortUrlsCommand::class => [ShortUrl\DeleteShortUrlService::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [

View file

@ -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;
}
}

View file

@ -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]);
}
}

View file

@ -57,6 +57,10 @@ return [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
],
ShortUrl\Repository\ExpiredShortUrlsRepository::class => [
EntityRepositoryFactory::class,
ShortUrl\Entity\ShortUrl::class,
],
Tag\TagService::class => ConfigAbstractFactory::class,
@ -147,6 +151,7 @@ return [
'em',
Options\DeleteShortUrlsOptions::class,
ShortUrl\ShortUrlResolver::class,
ShortUrl\Repository\ExpiredShortUrlsRepository::class,
],
ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class],
ShortUrl\ShortUrlVisitsDeleter::class => [

View file

@ -8,15 +8,18 @@ use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
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\Repository\ExpiredShortUrlsRepositoryInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly DeleteShortUrlsOptions $deleteShortUrlsOptions,
private readonly ShortUrlResolverInterface $urlResolver,
private EntityManagerInterface $em,
private DeleteShortUrlsOptions $deleteShortUrlsOptions,
private ShortUrlResolverInterface $urlResolver,
private ExpiredShortUrlsRepositoryInterface $expiredShortUrlsRepository,
) {
}
@ -47,4 +50,14 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
$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);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -19,4 +20,14 @@ interface DeleteShortUrlServiceInterface
bool $ignoreThreshold = false,
?ApiKey $apiKey = null,
): 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;
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
@ -147,7 +146,10 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh
$qb->expr()->isNull('s.maxVisits'),
$qb->expr()->gt(
'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,
),
),
));
}

View file

@ -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()));
}
}
}
}

View file

@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService;
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\Repository\ExpiredShortUrlsRepository;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
@ -26,6 +28,7 @@ class DeleteShortUrlServiceTest extends TestCase
{
private MockObject & EntityManagerInterface $em;
private MockObject & ShortUrlResolverInterface $urlResolver;
private MockObject & ExpiredShortUrlsRepository $expiredShortUrlsRepository;
private string $shortCode;
protected function setUp(): void
@ -39,6 +42,8 @@ class DeleteShortUrlServiceTest extends TestCase
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
$this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl);
$this->expiredShortUrlsRepository = $this->createMock(ExpiredShortUrlsRepository::class);
}
#[Test]
@ -94,11 +99,33 @@ class DeleteShortUrlServiceTest extends TestCase
$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
{
return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions(
$visitsThreshold,
$checkVisitsThreshold,
), $this->urlResolver);
), $this->urlResolver, $this->expiredShortUrlsRepository);
}
}