mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-22 08:56:42 -06:00
Merge pull request #1076 from acelaya-forks/feature/import-from-shlink
Feature/import from shlink
This commit is contained in:
commit
8a8e3c3fc8
@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one.
|
||||
|
||||
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
|
||||
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
|
||||
|
||||
### Changed
|
||||
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.
|
||||
|
@ -49,7 +49,7 @@
|
||||
"shlinkio/shlink-common": "dev-main#554e370 as 3.7",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^2.1",
|
||||
"shlinkio/shlink-importer": "^2.2",
|
||||
"shlinkio/shlink-importer": "dev-main#39928b6 as 2.3",
|
||||
"shlinkio/shlink-installer": "dev-develop#aa50ea9 as 5.5",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.5",
|
||||
"symfony/console": "^5.1",
|
||||
@ -124,6 +124,7 @@
|
||||
],
|
||||
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
|
||||
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
|
||||
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
|
||||
@ -132,7 +133,6 @@
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
|
||||
"infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests",
|
||||
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
|
||||
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json",
|
||||
|
@ -2,14 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
@ -18,7 +10,6 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -11,7 +11,6 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Laminas\Stdlib\Glob;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use PDO;
|
||||
use PHPUnit\Runner\Version;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
@ -55,10 +54,6 @@ $buildDbConnection = function (): array {
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
|
@ -42,12 +42,6 @@ $helper = new class {
|
||||
];
|
||||
}
|
||||
|
||||
$driverOptions = ! $isMysql ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
'dbname' => env('DB_NAME', 'shlink'),
|
||||
@ -55,7 +49,6 @@ $helper = new class {
|
||||
'password' => env('DB_PASSWORD'),
|
||||
'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
|
||||
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
|
||||
'driverOptions' => $driverOptions,
|
||||
'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null,
|
||||
];
|
||||
}
|
||||
|
@ -103,7 +103,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([
|
||||
Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
|
||||
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
),
|
||||
])),
|
||||
)->shouldBeCalledOnce();
|
||||
|
@ -76,7 +76,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
array $args
|
||||
): void {
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
@ -120,7 +120,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
@ -153,7 +153,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
public function errorWhileLocatingIpIsDisplayed(): void
|
||||
{
|
||||
$visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
|
@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\Entity;
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\Common\Collections\Selectable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
@ -86,17 +88,29 @@ class ShortUrl extends AbstractEntity
|
||||
?ShortUrlRelationResolverInterface $relationResolver = null
|
||||
): self {
|
||||
$meta = [
|
||||
ShortUrlInputFilter::VALIDATE_URL => false,
|
||||
ShortUrlInputFilter::LONG_URL => $url->longUrl(),
|
||||
ShortUrlInputFilter::DOMAIN => $url->domain(),
|
||||
ShortUrlInputFilter::TAGS => $url->tags(),
|
||||
ShortUrlInputFilter::TITLE => $url->title(),
|
||||
ShortUrlInputFilter::VALIDATE_URL => false,
|
||||
ShortUrlInputFilter::MAX_VISITS => $url->meta()->maxVisits(),
|
||||
];
|
||||
if ($importShortCode) {
|
||||
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode();
|
||||
}
|
||||
|
||||
$instance = self::fromMeta(ShortUrlMeta::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();
|
||||
$instance->importOriginalShortCode = $url->shortCode();
|
||||
$instance->dateCreated = Chronos::instance($url->createdAt());
|
||||
@ -152,6 +166,20 @@ class ShortUrl extends AbstractEntity
|
||||
return count($this->visits);
|
||||
}
|
||||
|
||||
public function mostRecentImportedVisitDate(): ?Chronos
|
||||
{
|
||||
/** @var Selectable $visits */
|
||||
$visits = $this->visits;
|
||||
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED))
|
||||
->orderBy(['id' => 'DESC'])
|
||||
->setMaxResults(1);
|
||||
|
||||
/** @var Visit|false $visit */
|
||||
$visit = $visits->matching($criteria)->last();
|
||||
|
||||
return $visit === false ? null : $visit->getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection|Visit[] $visits
|
||||
* @internal
|
||||
@ -167,7 +195,7 @@ class ShortUrl extends AbstractEntity
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
public function title(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
@ -11,32 +11,83 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
|
||||
|
||||
class Visit extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
public const TYPE_VALID_SHORT_URL = 'valid_short_url';
|
||||
public const TYPE_IMPORTED = 'imported';
|
||||
public const TYPE_INVALID_SHORT_URL = 'invalid_short_url';
|
||||
public const TYPE_BASE_URL = 'base_url';
|
||||
public const TYPE_REGULAR_404 = 'regular_404';
|
||||
|
||||
private string $referer;
|
||||
private Chronos $date;
|
||||
private ?string $remoteAddr;
|
||||
private ?string $visitedUrl;
|
||||
private ?string $remoteAddr = null;
|
||||
private ?string $visitedUrl = null;
|
||||
private string $userAgent;
|
||||
private string $type;
|
||||
private ?ShortUrl $shortUrl;
|
||||
private ?VisitLocation $visitLocation = null;
|
||||
|
||||
private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true)
|
||||
private function __construct(?ShortUrl $shortUrl, string $type)
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
$this->date = Chronos::now();
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
$instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL);
|
||||
$instance->hydrateFromVisitor($visitor, $anonymize);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self
|
||||
{
|
||||
$instance = new self($shortUrl, self::TYPE_IMPORTED);
|
||||
$instance->userAgent = $importedVisit->userAgent();
|
||||
$instance->referer = $importedVisit->referer();
|
||||
$instance->date = Chronos::instance($importedVisit->date());
|
||||
|
||||
$importedLocation = $importedVisit->location();
|
||||
$instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
$instance = new self(null, self::TYPE_BASE_URL);
|
||||
$instance->hydrateFromVisitor($visitor, $anonymize);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
$instance = new self(null, self::TYPE_INVALID_SHORT_URL);
|
||||
$instance->hydrateFromVisitor($visitor, $anonymize);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
$instance = new self(null, self::TYPE_REGULAR_404);
|
||||
$instance->hydrateFromVisitor($visitor, $anonymize);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void
|
||||
{
|
||||
$this->userAgent = $visitor->getUserAgent();
|
||||
$this->referer = $visitor->getReferer();
|
||||
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
|
||||
$this->visitedUrl = $visitor->getVisitedUrl();
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
private function processAddress(bool $anonymize, ?string $address): ?string
|
||||
@ -53,26 +104,6 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||
}
|
||||
}
|
||||
|
||||
public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize);
|
||||
}
|
||||
|
||||
public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
|
||||
{
|
||||
return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize);
|
||||
}
|
||||
|
||||
public function getRemoteAddr(): ?string
|
||||
{
|
||||
return $this->remoteAddr;
|
||||
@ -119,6 +150,15 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Needed only for ArrayCollections to be able to apply criteria filtering
|
||||
* @internal
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type();
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
@ -19,9 +20,53 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
private string $timezone;
|
||||
private bool $isEmpty;
|
||||
|
||||
public function __construct(Location $location)
|
||||
private function __construct()
|
||||
{
|
||||
$this->exchangeLocationInfo($location);
|
||||
}
|
||||
|
||||
public static function fromGeolocation(Location $location): self
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$instance->countryCode = $location->countryCode();
|
||||
$instance->countryName = $location->countryName();
|
||||
$instance->regionName = $location->regionName();
|
||||
$instance->cityName = $location->city();
|
||||
$instance->latitude = $location->latitude();
|
||||
$instance->longitude = $location->longitude();
|
||||
$instance->timezone = $location->timeZone();
|
||||
$instance->computeIsEmpty();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromImport(ImportedShlinkVisitLocation $location): self
|
||||
{
|
||||
$instance = new self();
|
||||
|
||||
$instance->countryCode = $location->countryCode();
|
||||
$instance->countryName = $location->countryName();
|
||||
$instance->regionName = $location->regionName();
|
||||
$instance->cityName = $location->cityName();
|
||||
$instance->latitude = $location->latitude();
|
||||
$instance->longitude = $location->longitude();
|
||||
$instance->timezone = $location->timeZone();
|
||||
$instance->computeIsEmpty();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function computeIsEmpty(): void
|
||||
{
|
||||
$this->isEmpty = (
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === ''
|
||||
);
|
||||
}
|
||||
|
||||
public function getCountryName(): string
|
||||
@ -49,26 +94,6 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
return $this->isEmpty;
|
||||
}
|
||||
|
||||
private function exchangeLocationInfo(Location $info): void
|
||||
{
|
||||
$this->countryCode = $info->countryCode();
|
||||
$this->countryName = $info->countryName();
|
||||
$this->regionName = $info->regionName();
|
||||
$this->cityName = $info->city();
|
||||
$this->latitude = $info->latitude();
|
||||
$this->longitude = $info->longitude();
|
||||
$this->timezone = $info->timeZone();
|
||||
$this->isEmpty = (
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === ''
|
||||
);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
@ -71,7 +71,7 @@ class LocateVisit
|
||||
try {
|
||||
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$visit->locate(VisitLocation::fromGeolocation($location));
|
||||
$this->em->flush();
|
||||
} catch (WrongIpException $e) {
|
||||
$this->logger->warning(
|
||||
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@ -34,4 +35,9 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
|
||||
|
||||
return $e;
|
||||
}
|
||||
|
||||
public static function fromImport(ImportedShlinkUrl $importedUrl): self
|
||||
{
|
||||
return self::fromSlug($importedUrl->shortCode(), $importedUrl->domain());
|
||||
}
|
||||
}
|
||||
|
@ -6,12 +6,14 @@ namespace Shlinkio\Shlink\Core\Importer;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
|
||||
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use Shlinkio\Shlink\Importer\Sources\ImportSources;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
use function sprintf;
|
||||
@ -22,6 +24,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
private ShortUrlRelationResolverInterface $relationResolver;
|
||||
private ShortCodeHelperInterface $shortCodeHelper;
|
||||
private DoctrineBatchHelperInterface $batchHelper;
|
||||
private ShortUrlRepositoryInterface $shortUrlRepo;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
@ -33,6 +36,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
$this->relationResolver = $relationResolver;
|
||||
$this->shortCodeHelper = $shortCodeHelper;
|
||||
$this->batchHelper = $batchHelper;
|
||||
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
@ -40,51 +44,65 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
|
||||
*/
|
||||
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
|
||||
{
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$importShortCodes = $params['import_short_codes'];
|
||||
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100);
|
||||
$source = $params['source'];
|
||||
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100);
|
||||
|
||||
/** @var ImportedShlinkUrl $url */
|
||||
foreach ($iterable as $url) {
|
||||
$longUrl = $url->longUrl();
|
||||
/** @var ImportedShlinkUrl $importedUrl */
|
||||
foreach ($iterable as $importedUrl) {
|
||||
$skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool {
|
||||
$action = $io->choice(sprintf(
|
||||
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate '
|
||||
. 'a new one or skip it?',
|
||||
$importedUrl->longUrl(),
|
||||
$importedUrl->shortCode(),
|
||||
), ['Generate new short-code', 'Skip'], 1);
|
||||
|
||||
// Skip already imported URLs
|
||||
if ($shortUrlRepo->importedUrlExists($url)) {
|
||||
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
|
||||
return $action === 'Skip';
|
||||
};
|
||||
$longUrl = $importedUrl->longUrl();
|
||||
|
||||
try {
|
||||
$shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict);
|
||||
} catch (NonUniqueSlugException $e) {
|
||||
$io->text(sprintf('%s: <fg=red>Error</>', $longUrl));
|
||||
continue;
|
||||
}
|
||||
|
||||
$shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
|
||||
if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->em->persist($shortUrl);
|
||||
$io->text(sprintf('%s: <info>Imported</info>', $longUrl));
|
||||
$resultMessage = $shortUrlImporting->importVisits($importedUrl->visits(), $this->em);
|
||||
$io->text(sprintf('%s: %s', $longUrl, $resultMessage));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveShortUrl(
|
||||
ImportedShlinkUrl $importedUrl,
|
||||
bool $importShortCodes,
|
||||
callable $skipOnShortCodeConflict
|
||||
): ShortUrlImporting {
|
||||
$alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl);
|
||||
if ($alreadyImportedShortUrl !== null) {
|
||||
return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl);
|
||||
}
|
||||
|
||||
$shortUrl = ShortUrl::fromImport($importedUrl, $importShortCodes, $this->relationResolver);
|
||||
if (! $this->handleShortCodeUniqueness($shortUrl, $importShortCodes, $skipOnShortCodeConflict)) {
|
||||
throw NonUniqueSlugException::fromImport($importedUrl);
|
||||
}
|
||||
|
||||
$this->em->persist($shortUrl);
|
||||
return ShortUrlImporting::fromNewShortUrl($shortUrl);
|
||||
}
|
||||
|
||||
private function handleShortCodeUniqueness(
|
||||
ImportedShlinkUrl $url,
|
||||
ShortUrl $shortUrl,
|
||||
StyleInterface $io,
|
||||
bool $importShortCodes
|
||||
bool $importShortCodes,
|
||||
callable $skipOnShortCodeConflict
|
||||
): bool {
|
||||
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$longUrl = $url->longUrl();
|
||||
$action = $io->choice(sprintf(
|
||||
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate a new '
|
||||
. 'one or skip it?',
|
||||
$longUrl,
|
||||
$url->shortCode(),
|
||||
), ['Generate new short-code', 'Skip'], 1);
|
||||
|
||||
if ($action === 'Skip') {
|
||||
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
|
||||
if ($skipOnShortCodeConflict()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
65
module/Core/src/Importer/ShortUrlImporting.php
Normal file
65
module/Core/src/Importer/ShortUrlImporting.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Importer;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class ShortUrlImporting
|
||||
{
|
||||
private ShortUrl $shortUrl;
|
||||
private bool $isNew;
|
||||
|
||||
private function __construct(ShortUrl $shortUrl, bool $isNew)
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
$this->isNew = $isNew;
|
||||
}
|
||||
|
||||
public static function fromExistingShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
return new self($shortUrl, false);
|
||||
}
|
||||
|
||||
public static function fromNewShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
return new self($shortUrl, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable|ImportedShlinkVisit[] $visits
|
||||
*/
|
||||
public function importVisits(iterable $visits, EntityManagerInterface $em): string
|
||||
{
|
||||
$mostRecentImportedDate = $this->shortUrl->mostRecentImportedVisitDate();
|
||||
|
||||
$importedVisits = 0;
|
||||
foreach ($visits as $importedVisit) {
|
||||
// Skip visits which are older than the most recent already imported visit's date
|
||||
if (
|
||||
$mostRecentImportedDate !== null
|
||||
&& $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date()))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$em->persist(Visit::fromImport($this->shortUrl, $importedVisit));
|
||||
$importedVisits++;
|
||||
}
|
||||
|
||||
if ($importedVisits === 0) {
|
||||
return $this->isNew ? '<info>Imported</info>' : '<comment>Skipped</comment>';
|
||||
}
|
||||
|
||||
return $this->isNew
|
||||
? sprintf('<info>Imported</info> with <info>%s</info> visits', $importedVisits)
|
||||
: sprintf('<comment>Skipped</comment>. Imported <info>%s</info> visits', $importedVisits);
|
||||
}
|
||||
}
|
@ -264,12 +264,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function importedUrlExists(ImportedShlinkUrl $url): bool
|
||||
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('COUNT(DISTINCT s.id)')
|
||||
->from(ShortUrl::class, 's')
|
||||
->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
|
||||
$qb = $this->createQueryBuilder('s');
|
||||
$qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $url->shortCode())
|
||||
->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
|
||||
->setParameter('importSource', $url->source())
|
||||
@ -277,8 +275,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
|
||||
|
||||
$this->whereDomainIs($qb, $url->domain());
|
||||
|
||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
||||
return $result > 0;
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
private function whereDomainIs(QueryBuilder $qb, ?string $domain): void
|
||||
|
@ -40,5 +40,5 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
|
||||
|
||||
public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl;
|
||||
|
||||
public function importedUrlExists(ImportedShlinkUrl $url): bool;
|
||||
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl;
|
||||
}
|
||||
|
@ -203,6 +203,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
|
||||
|
||||
private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
|
||||
{
|
||||
// TODO Order by date and ID, not just by ID (order by date DESC, id DESC).
|
||||
// That ensures imported visits are properly ordered even if inserted in wrong chronological order.
|
||||
|
||||
$qb->select('v.id')
|
||||
->orderBy('v.id', 'DESC')
|
||||
// Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
|
||||
|
@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
|
||||
class ShortCodeHelper implements ShortCodeHelperInterface
|
||||
class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
|
@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
|
||||
interface ShortCodeHelperInterface
|
||||
interface ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelperInterface
|
||||
{
|
||||
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool;
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
'tags' => invoke($shortUrl->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($shortUrl),
|
||||
'domain' => $shortUrl->getDomain(),
|
||||
'title' => $shortUrl->getTitle(),
|
||||
'title' => $shortUrl->title(),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -63,8 +63,7 @@ class VisitLocator implements VisitLocatorInterface
|
||||
$location = Location::emptyInstance();
|
||||
}
|
||||
|
||||
$location = new VisitLocation($location);
|
||||
$this->locateVisit($visit, $location, $helper);
|
||||
$this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper);
|
||||
|
||||
// Flush and clear after X iterations
|
||||
if ($count % $persistBlock === 0) {
|
||||
|
@ -416,7 +416,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function importedShortUrlsAreSearchedAsExpected(): void
|
||||
public function importedShortUrlsAreFoundWhenExpected(): void
|
||||
{
|
||||
$buildImported = static fn (string $shortCode, ?String $domain = null) =>
|
||||
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null);
|
||||
@ -429,11 +429,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
self::assertTrue($this->repo->importedUrlExists($buildImported('my-cool-slug')));
|
||||
self::assertTrue($this->repo->importedUrlExists($buildImported('another-slug', 'doma.in')));
|
||||
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug')));
|
||||
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug', 'doma.in')));
|
||||
self::assertFalse($this->repo->importedUrlExists($buildImported('my-cool-slug', 'doma.in')));
|
||||
self::assertFalse($this->repo->importedUrlExists($buildImported('another-slug')));
|
||||
self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug')));
|
||||
self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 'doma.in')));
|
||||
self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug')));
|
||||
self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 'doma.in')));
|
||||
self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 'doma.in')));
|
||||
self::assertNull($this->repo->findOneByImportedUrl($buildImported('another-slug')));
|
||||
}
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
$visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
if ($i >= 2) {
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$location = VisitLocation::fromGeolocation(Location::emptyInstance());
|
||||
$this->getEntityManager()->persist($location);
|
||||
$visit->locate($location);
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ class VisitLocationTest extends TestCase
|
||||
public function isEmptyReturnsTrueWhenAllValuesAreEmpty(array $args, bool $isEmpty): void
|
||||
{
|
||||
$payload = new Location(...$args);
|
||||
$location = new VisitLocation($payload);
|
||||
$location = VisitLocation::fromGeolocation($payload);
|
||||
|
||||
self::assertEquals($isEmpty, $location->isEmpty());
|
||||
}
|
||||
|
@ -168,7 +168,7 @@ class LocateVisitTest extends TestCase
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
self::assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance()));
|
||||
self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance()));
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
@ -204,7 +204,7 @@ class LocateVisitTest extends TestCase
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
self::assertEquals($visit->getVisitLocation(), new VisitLocation($location));
|
||||
self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location));
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldHaveBeenCalledOnce();
|
||||
|
@ -5,18 +5,22 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\Core\Importer;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
|
||||
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit;
|
||||
use Shlinkio\Shlink\Importer\Sources\ImportSources;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
use function count;
|
||||
@ -28,6 +32,8 @@ class ImportedLinksProcessorTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY];
|
||||
|
||||
private ImportedLinksProcessor $processor;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $shortCodeHelper;
|
||||
@ -64,11 +70,11 @@ class ImportedLinksProcessorTest extends TestCase
|
||||
];
|
||||
$expectedCalls = count($urls);
|
||||
|
||||
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false);
|
||||
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null);
|
||||
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
|
||||
$persist = $this->em->persist(Argument::type(ShortUrl::class));
|
||||
|
||||
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
|
||||
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
|
||||
|
||||
$importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls);
|
||||
$ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls);
|
||||
@ -86,24 +92,25 @@ class ImportedLinksProcessorTest extends TestCase
|
||||
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
|
||||
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null),
|
||||
];
|
||||
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);
|
||||
|
||||
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->will(function (array $args): bool {
|
||||
/** @var ImportedShlinkUrl $url */
|
||||
[$url] = $args;
|
||||
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->will(
|
||||
function (array $args): ?ShortUrl {
|
||||
/** @var ImportedShlinkUrl $url */
|
||||
[$url] = $args;
|
||||
|
||||
return contains(['foo', 'baz2', 'baz3'], $url->longUrl());
|
||||
});
|
||||
return contains(['foo', 'baz2', 'baz3'], $url->longUrl()) ? ShortUrl::fromImport($url, true) : null;
|
||||
},
|
||||
);
|
||||
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
|
||||
$persist = $this->em->persist(Argument::type(ShortUrl::class));
|
||||
|
||||
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
|
||||
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
|
||||
|
||||
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
|
||||
$ensureUniqueness->shouldHaveBeenCalledTimes(2);
|
||||
$persist->shouldHaveBeenCalledTimes(2);
|
||||
$this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3);
|
||||
$this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2);
|
||||
$this->io->text(Argument::containingString('Skipped'))->shouldHaveBeenCalledTimes(3);
|
||||
$this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@ -116,9 +123,8 @@ class ImportedLinksProcessorTest extends TestCase
|
||||
new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null),
|
||||
new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'),
|
||||
];
|
||||
$contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle);
|
||||
|
||||
$importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false);
|
||||
$importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null);
|
||||
$failingEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(
|
||||
Argument::any(),
|
||||
true,
|
||||
@ -135,14 +141,77 @@ class ImportedLinksProcessorTest extends TestCase
|
||||
});
|
||||
$persist = $this->em->persist(Argument::type(ShortUrl::class));
|
||||
|
||||
$this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]);
|
||||
$this->processor->process($this->io->reveal(), $urls, self::PARAMS);
|
||||
|
||||
$importedUrlExists->shouldHaveBeenCalledTimes(count($urls));
|
||||
$failingEnsureUniqueness->shouldHaveBeenCalledTimes(5);
|
||||
$successEnsureUniqueness->shouldHaveBeenCalledTimes(2);
|
||||
$choice->shouldHaveBeenCalledTimes(5);
|
||||
$persist->shouldHaveBeenCalledTimes(2);
|
||||
$this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3);
|
||||
$this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2);
|
||||
$this->io->text(Argument::containingString('Error'))->shouldHaveBeenCalledTimes(3);
|
||||
$this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideUrlsWithVisits
|
||||
*/
|
||||
public function properAmountOfVisitsIsImported(
|
||||
ImportedShlinkUrl $importedUrl,
|
||||
string $expectedOutput,
|
||||
int $amountOfPersistedVisits,
|
||||
?ShortUrl $foundShortUrl
|
||||
): void {
|
||||
$findExisting = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn($foundShortUrl);
|
||||
$ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
|
||||
$persistUrl = $this->em->persist(Argument::type(ShortUrl::class));
|
||||
$persistVisits = $this->em->persist(Argument::type(Visit::class));
|
||||
|
||||
$this->processor->process($this->io->reveal(), [$importedUrl], self::PARAMS);
|
||||
|
||||
$findExisting->shouldHaveBeenCalledOnce();
|
||||
$ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
|
||||
$persistUrl->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0);
|
||||
$persistVisits->shouldHaveBeenCalledTimes($amountOfPersistedVisits);
|
||||
$this->io->text(Argument::containingString($expectedOutput))->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideUrlsWithVisits(): iterable
|
||||
{
|
||||
$now = Chronos::now();
|
||||
$createImportedUrl = fn (array $visits) => new ImportedShlinkUrl('', 's', [], $now, null, 's', null, $visits);
|
||||
|
||||
yield 'new short URL' => [$createImportedUrl([
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
]), '<info>Imported</info> with <info>5</info> visits', 5, null];
|
||||
yield 'existing short URL without previous imported visits' => [
|
||||
$createImportedUrl([
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now->addDays(3), null),
|
||||
new ImportedShlinkVisit('', '', $now->addDays(3), null),
|
||||
]),
|
||||
'<comment>Skipped</comment>. Imported <info>4</info> visits',
|
||||
4,
|
||||
ShortUrl::createEmpty(),
|
||||
];
|
||||
yield 'existing short URL with previous imported visits' => [
|
||||
$createImportedUrl([
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now, null),
|
||||
new ImportedShlinkVisit('', '', $now->addDays(3), null),
|
||||
new ImportedShlinkVisit('', '', $now->addDays(3), null),
|
||||
]),
|
||||
'<comment>Skipped</comment>. Imported <info>2</info> visits',
|
||||
2,
|
||||
ShortUrl::createEmpty()->setVisits(new ArrayCollection([
|
||||
Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)),
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ class OrphanVisitDataTransformerTest extends TestCase
|
||||
->withHeader('Referer', 'referer')
|
||||
->withUri(new Uri('https://doma.in/foo/bar')),
|
||||
),
|
||||
)->locate($location = new VisitLocation(Location::emptyInstance())),
|
||||
)->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())),
|
||||
[
|
||||
'referer' => 'referer',
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
|
Loading…
Reference in New Issue
Block a user