Merge pull request #1619 from acelaya-forks/feature/import-orphan-visits

Feature/import orphan visits
This commit is contained in:
Alejandro Celaya 2022-12-05 15:03:28 +01:00 committed by GitHub
commit 54bc169525
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 217 additions and 44 deletions

View file

@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* *Nothing*
* [#1616](https://github.com/shlinkio/shlink/issues/1616) Added support to import orphan visits when importing short URLs from another Shlink instance.
### Changed
* [#1563](https://github.com/shlinkio/shlink/issues/1563) Moved logic to reuse command options to option classes instead of base abstract command classes.
@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* *Nothing*
### Fixed
* *Nothing*
* [#1618](https://github.com/shlinkio/shlink/issues/1618) Fixed imported short URLs and visits dates not being set to the target server timezone.
## [3.3.2] - 2022-10-18

View file

@ -48,7 +48,7 @@
"shlinkio/shlink-common": "dev-main#7515008 as 5.2",
"shlinkio/shlink-config": "dev-main#96c81fb as 2.3",
"shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^4.0",
"shlinkio/shlink-importer": "dev-main#c97662b as 5.0",
"shlinkio/shlink-installer": "^8.2",
"shlinkio/shlink-ip-geolocation": "dev-main#e208963 as 3.2",
"spiral/roadrunner": "^2.11",

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use Cake\Chronos\ChronosInterface;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
@ -35,7 +36,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]));
}
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
@ -46,7 +47,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en
return buildDateRange($startDate, $endDate);
}
function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
/**
* @return ($date is null ? null : Chronos)
*/
function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $date): ?Chronos
{
$parsedDate = match (true) {
$date === null || $date instanceof Chronos => $date,
@ -57,6 +61,11 @@ function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos
return $parsedDate?->setTimezone(date_default_timezone_get());
}
function normalizeDate(string|DateTimeInterface|ChronosInterface $date): Chronos
{
return normalizeOptionalDate($date);
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);

View file

@ -11,39 +11,53 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportResult;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\StyleInterface;
use Throwable;
use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
class ImportedLinksProcessor implements ImportedLinksProcessorInterface
{
private ShortUrlRepositoryInterface $shortUrlRepo;
public function __construct(
private readonly EntityManagerInterface $em,
private readonly ShortUrlRelationResolverInterface $relationResolver,
private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper,
private readonly DoctrineBatchHelperInterface $batchHelper,
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
}
public function process(StyleInterface $io, ImportResult $result, ImportParams $params): void
{
$io->title('Importing short URLs');
$this->importShortUrls($io, $result->shlinkUrls, $params);
if ($params->importOrphanVisits) {
$io->title('Importing orphan visits');
$this->importOrphanVisits($io, $result->orphanVisits);
}
$io->success('Data properly imported!');
}
/**
* @param iterable<ImportedShlinkUrl> $shlinkUrls
*/
public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
private function importShortUrls(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
{
$importShortCodes = $params->importShortCodes;
$source = $params->source;
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100);
/** @var ImportedShlinkUrl $importedUrl */
foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
@ -78,7 +92,9 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
bool $importShortCodes,
callable $skipOnShortCodeConflict,
): ShortUrlImporting {
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$alreadyImportedShortUrl = $shortUrlRepo->findOneByImportedUrl($importedUrl);
if ($alreadyImportedShortUrl !== null) {
return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl);
}
@ -107,4 +123,29 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false);
}
/**
* @param iterable<ImportedShlinkOrphanVisit> $orphanVisits
*/
private function importOrphanVisits(StyleInterface $io, iterable $orphanVisits): void
{
$iterable = $this->batchHelper->wrapIterable($orphanVisits, 100);
/** @var VisitRepositoryInterface $visitRepo */
$visitRepo = $this->em->getRepository(Visit::class);
$mostRecentOrphanVisit = $visitRepo->findMostRecentOrphanVisit();
$importedVisits = 0;
foreach ($iterable as $importedOrphanVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentOrphanVisit?->getDate()->gte(normalizeDate($importedOrphanVisit->date))) {
continue;
}
$this->em->persist(Visit::fromOrphanImport($importedOrphanVisit));
$importedVisits++;
}
$io->text(sprintf('<info>Imported %s</info> orphan visits.', $importedVisits));
}
}

View file

@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Importer;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\normalizeDate;
use function sprintf;
final class ShortUrlImporting
@ -38,7 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date))) {
if ($mostRecentImportedDate?->gte(normalizeDate($importedVisit->date))) {
continue;
}

View file

@ -25,6 +25,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function count;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
class ShortUrl extends AbstractEntity
{
@ -109,19 +111,11 @@ class ShortUrl extends AbstractEntity
$instance = self::fromMeta(ShortUrlCreation::fromRawData($meta), $relationResolver);
$validSince = $url->meta->validSince;
if ($validSince !== null) {
$instance->validSince = Chronos::instance($validSince);
}
$validUntil = $url->meta->validUntil;
if ($validUntil !== null) {
$instance->validUntil = Chronos::instance($validUntil);
}
$instance->importSource = $url->source->value;
$instance->importOriginalShortCode = $url->shortCode;
$instance->dateCreated = Chronos::instance($url->createdAt);
$instance->validSince = normalizeOptionalDate($url->meta->validSince);
$instance->validUntil = normalizeOptionalDate($url->meta->validUntil);
$instance->dateCreated = normalizeDate($url->createdAt);
return $instance;
}

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
@ -68,8 +68,8 @@ final class ShortUrlCreation implements TitleResolutionModelInterface
}
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS);

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlEdition implements TitleResolutionModelInterface
{
@ -69,8 +69,8 @@ final class ShortUrlEdition implements TitleResolutionModelInterface
$this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data);
$this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
$this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
$this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL));
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false;
$this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);

View file

@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\normalizeDate;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
final class ShortUrlsParams
{
@ -59,8 +59,8 @@ final class ShortUrlsParams
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
$this->dateRange = buildDateRange(
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);
$this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (

View file

@ -6,5 +6,10 @@ namespace Shlinkio\Shlink\Core\Util;
interface DoctrineBatchHelperInterface
{
/**
* @template T
* @param iterable<T> $resultSet
* @return iterable<T>
*/
public function wrapIterable(iterable $resultSet, int $batchSize): iterable;
}

View file

@ -10,12 +10,13 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitType;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use function Shlinkio\Shlink\Core\isCrawler;
use function Shlinkio\Shlink\Core\normalizeDate;
class Visit extends AbstractEntity implements JsonSerializable
{
@ -46,11 +47,30 @@ class Visit extends AbstractEntity implements JsonSerializable
public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self
{
$instance = new self($shortUrl, VisitType::IMPORTED);
return self::fromImportOrOrphanImport($importedVisit, VisitType::IMPORTED, $shortUrl);
}
public static function fromOrphanImport(ImportedShlinkOrphanVisit $importedVisit): self
{
$instance = self::fromImportOrOrphanImport(
$importedVisit,
VisitType::tryFrom($importedVisit->type) ?? VisitType::IMPORTED,
);
$instance->visitedUrl = $importedVisit->visitedUrl;
return $instance;
}
private static function fromImportOrOrphanImport(
ImportedShlinkVisit|ImportedShlinkOrphanVisit $importedVisit,
VisitType $type,
?ShortUrl $shortUrl = null,
): self {
$instance = new self($shortUrl, $type);
$instance->userAgent = $importedVisit->userAgent;
$instance->potentialBot = isCrawler($instance->userAgent);
$instance->referer = $importedVisit->referer;
$instance->date = Chronos::instance($importedVisit->date);
$instance->date = normalizeDate($importedVisit->date);
$importedLocation = $importedVisit->location;
$instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null;

View file

@ -286,4 +286,19 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult();
}
public function findMostRecentOrphanVisit(): ?Visit
{
$dql = <<<DQL
SELECT v
FROM Shlinkio\Shlink\Core\Visit\Entity\Visit AS v
WHERE v.shortUrl IS NULL
ORDER BY v.id DESC
DQL;
$query = $this->getEntityManager()->createQuery($dql);
$query->setMaxResults(1);
return $query->getOneOrNullResult();
}
}

View file

@ -65,4 +65,6 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function findNonOrphanVisits(VisitsListFiltering $filtering): array;
public function countNonOrphanVisits(VisitsCountFiltering $filtering): int;
public function findMostRecentOrphanVisit(): ?Visit;
}

View file

@ -491,6 +491,24 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5)));
}
/** @test */
public function findMostRecentOrphanVisitReturnsExpectedVisit(): void
{
$this->assertNull($this->repo->findMostRecentOrphanVisit());
$lastVisit = Visit::forBasePath(Visitor::emptyInstance());
$this->getEntityManager()->persist($lastVisit);
$this->getEntityManager()->flush();
$this->assertSame($lastVisit, $this->repo->findMostRecentOrphanVisit());
$lastVisit2 = Visit::forRegularNotFound(Visitor::botInstance());
$this->getEntityManager()->persist($lastVisit2);
$this->getEntityManager()->flush();
$this->assertSame($lastVisit2, $this->repo->findMostRecentOrphanVisit());
}
/**
* @return array{string, string, \Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl}
*/

View file

@ -17,8 +17,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
use Shlinkio\Shlink\Importer\Model\ImportResult;
use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
use stdClass;
@ -27,6 +31,7 @@ use Symfony\Component\Console\Style\StyleInterface;
use function count;
use function Functional\contains;
use function Functional\some;
use function sprintf;
use function str_contains;
class ImportedLinksProcessorTest extends TestCase
@ -41,7 +46,6 @@ class ImportedLinksProcessorTest extends TestCase
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->repo = $this->createMock(ShortUrlRepositoryInterface::class);
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class);
$batchHelper = $this->createMock(DoctrineBatchHelperInterface::class);
@ -67,6 +71,7 @@ class ImportedLinksProcessorTest extends TestCase
];
$expectedCalls = count($urls);
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->repo->expects($this->exactly($expectedCalls))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly($expectedCalls))
->method('ensureShortCodeUniqueness')
@ -76,7 +81,7 @@ class ImportedLinksProcessorTest extends TestCase
);
$this->io->expects($this->exactly($expectedCalls))->method('text')->with($this->isType('string'));
$this->processor->process($this->io, $urls, $this->buildParams());
$this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams());
}
/** @test */
@ -88,6 +93,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', null),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->repo->expects($this->exactly(3))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly(3))->method('ensureShortCodeUniqueness')->willReturn(true);
$this->em->expects($this->exactly(3))->method('persist')->with(
@ -99,7 +105,7 @@ class ImportedLinksProcessorTest extends TestCase
});
$textCalls = $this->setUpIoText('<comment>Skipped</comment>. Reason: Whatever error', '<info>Imported</info>');
$this->processor->process($this->io, $urls, $this->buildParams());
$this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams());
self::assertEquals(2, $textCalls->importedCount);
self::assertEquals(1, $textCalls->skippedCount);
@ -116,6 +122,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', null),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturnCallback(
fn (ImportedShlinkUrl $url): ?ShortUrl
=> contains(['foo', 'baz2', 'baz3'], $url->longUrl) ? ShortUrl::fromImport($url, true) : null,
@ -124,7 +131,7 @@ class ImportedLinksProcessorTest extends TestCase
$this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ShortUrl::class));
$textCalls = $this->setUpIoText();
$this->processor->process($this->io, $urls, $this->buildParams());
$this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams());
self::assertEquals(2, $textCalls->importedCount);
self::assertEquals(3, $textCalls->skippedCount);
@ -141,6 +148,7 @@ class ImportedLinksProcessorTest extends TestCase
new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', 'bar'),
];
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturn(null);
$this->shortCodeHelper->expects($this->exactly(7))->method('ensureShortCodeUniqueness')->willReturnCallback(
fn ($_, bool $hasCustomSlug) => ! $hasCustomSlug,
@ -151,7 +159,7 @@ class ImportedLinksProcessorTest extends TestCase
});
$textCalls = $this->setUpIoText('Error');
$this->processor->process($this->io, $urls, $this->buildParams());
$this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams());
self::assertEquals(2, $textCalls->importedCount);
self::assertEquals(3, $textCalls->skippedCount);
@ -167,6 +175,7 @@ class ImportedLinksProcessorTest extends TestCase
int $amountOfPersistedVisits,
?ShortUrl $foundShortUrl,
): void {
$this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo);
$this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($foundShortUrl);
$this->shortCodeHelper->expects($this->exactly($foundShortUrl === null ? 1 : 0))
->method('ensureShortCodeUniqueness')
@ -176,7 +185,7 @@ class ImportedLinksProcessorTest extends TestCase
)->with($this->callback(fn (object $arg) => $arg instanceof ShortUrl || $arg instanceof Visit));
$this->io->expects($this->once())->method('text')->with($this->stringContains($expectedOutput));
$this->processor->process($this->io, [$importedUrl], $this->buildParams());
$this->processor->process($this->io, ImportResult::withShortUrls([$importedUrl]), $this->buildParams());
}
public function provideUrlsWithVisits(): iterable
@ -219,9 +228,69 @@ class ImportedLinksProcessorTest extends TestCase
];
}
private function buildParams(): ImportParams
/**
* @param iterable<ImportedShlinkOrphanVisit> $visits
* @test
* @dataProvider provideOrphanVisits
*/
public function properAmountOfOrphanVisitsIsImported(
bool $importOrphanVisits,
iterable $visits,
?Visit $lastOrphanVisit,
int $expectedImportedVisits,
): void {
$this->io->expects($this->exactly($importOrphanVisits ? 2 : 1))->method('title');
$this->io->expects($importOrphanVisits ? $this->once() : $this->never())->method('text')->with(
sprintf('<info>Imported %s</info> orphan visits.', $expectedImportedVisits),
);
$visitRepo = $this->createMock(VisitRepositoryInterface::class);
$visitRepo->expects($importOrphanVisits ? $this->once() : $this->never())->method(
'findMostRecentOrphanVisit',
)->willReturn($lastOrphanVisit);
$this->em->expects($importOrphanVisits ? $this->once() : $this->never())->method('getRepository')->with(
Visit::class,
)->willReturn($visitRepo);
$this->em->expects($importOrphanVisits ? $this->exactly($expectedImportedVisits) : $this->never())->method(
'persist',
)->with($this->isInstanceOf(Visit::class));
$this->processor->process(
$this->io,
ImportResult::withShortUrlsAndOrphanVisits([], $visits),
$this->buildParams($importOrphanVisits),
);
}
public function provideOrphanVisits(): iterable
{
return ImportSource::BITLY->toParamsWithCallableMap(['import_short_codes' => static fn () => true]);
yield 'import orphan disable without visits' => [false, [], null, 0];
yield 'import orphan enabled without visits' => [true, [], null, 0];
yield 'import orphan disabled with visits' => [false, [
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
], null, 0];
yield 'import orphan enabled with visits' => [true, [
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null),
], null, 5];
yield 'existing orphan visit' => [true, [
new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(3), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(2), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null),
new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null),
], Visit::forBasePath(Visitor::botInstance()), 3];
}
private function buildParams(bool $importOrphanVisits = false): ImportParams
{
return ImportSource::BITLY->toParamsWithCallableMap([
ImportParams::IMPORT_SHORT_CODES_PARAM => static fn () => true,
ImportParams::IMPORT_ORPHAN_VISITS_PARAM => static fn () => $importOrphanVisits,
]);
}
public function setUpIoText(string $skippedText = 'Skipped', string $importedText = 'Imported'): stdClass