Use valinor to validate and map short URL creation

This commit is contained in:
Alejandro Celaya
2026-04-30 22:17:38 +02:00
parent 83eb78528d
commit 8c3240c7bf
39 changed files with 591 additions and 766 deletions
+1 -1
View File
@@ -42,7 +42,7 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
"shlinkio/shlink-common": "dev-main#6b13d94 as 8.2.0",
"shlinkio/shlink-common": "dev-main#ced752b as 8.2.0",
"shlinkio/shlink-config": "^4.1.0",
"shlinkio/shlink-event-dispatcher": "^4.4.0",
"shlinkio/shlink-importer": "^5.7.0",
+3 -1
View File
@@ -13,10 +13,12 @@ const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const DEFAULT_REDIRECT_CACHE_VISIBILITY = 'private';
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
/** @deprecated */
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
/**
* List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
@@ -6,12 +6,13 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use function max;
/**
* Data used for short URL creation
*/
@@ -43,15 +44,15 @@ final class ShortUrlCreationInput
public function toShortUrlCreation(UrlShortenerOptions $options): ShortUrlCreation
{
$shortCodeLength = $this->shortCodeLength ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $this->longUrl,
ShortUrlInputFilter::DOMAIN => $this->domain,
ShortUrlInputFilter::CUSTOM_SLUG => $this->customSlug,
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $this->pathPrefix,
ShortUrlInputFilter::FIND_IF_EXISTS => $this->findIfExists,
$shortCodeLength = max(4, $this->shortCodeLength ?? $options->defaultShortCodesLength);
return new ShortUrlCreation(
$this->longUrl,
...$this->commonData->toArray(),
], $options);
customSlug: $this->customSlug,
pathPrefix: $this->pathPrefix,
findIfExists: $this->findIfExists,
domain: $this->domain,
shortCodeLength: $shortCodeLength,
);
}
}
@@ -121,11 +121,11 @@ class ListShortUrlsCommandTest extends TestCase
public static function provideOptionalFlags(): iterable
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')),
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo.com',
apiKey: ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')),
tags: ['foo', 'bar', 'baz'],
));
$shortCode = $shortUrl->getShortCode();
$created = $shortUrl->dateCreated()->toAtomString();
+13 -17
View File
@@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
@@ -30,7 +29,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function array_map;
use function count;
use function Shlinkio\Shlink\Common\normalizeDate;
use function Shlinkio\Shlink\Common\normalizeOptionalDate;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function sprintf;
@@ -79,7 +77,7 @@ class ShortUrl extends AbstractEntity
*/
public static function withLongUrl(string $longUrl): self
{
return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
return self::create(new ShortUrlCreation(longUrl: $longUrl));
}
public static function create(
@@ -94,6 +92,8 @@ class ShortUrl extends AbstractEntity
shortCode: sprintf(
'%s%s',
$creation->pathPrefix ?? '',
// TODO Encapsulate Generating the random short code into ShortUrlCreation, when custom slug is not set,
// then expose it as shortCode or something generic
$creation->customSlug ?? generateRandomShortCode($shortCodeLength, $creation->shortUrlMode),
),
tags: $relationResolver->resolveTags($creation->tags),
@@ -116,21 +116,17 @@ class ShortUrl extends AbstractEntity
bool $importShortCode,
ShortUrlRelationResolverInterface|null $relationResolver = null,
): self {
$meta = [
ShortUrlInputFilter::LONG_URL => $url->longUrl,
ShortUrlInputFilter::DOMAIN => $url->domain,
ShortUrlInputFilter::TAGS => $url->tags,
ShortUrlInputFilter::TITLE => $url->title,
ShortUrlInputFilter::MAX_VISITS => $url->meta->maxVisits,
];
if ($importShortCode) {
$meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode;
}
$instance = self::create(new ShortUrlCreation(
longUrl: $url->longUrl,
validSince: $url->meta->validSince,
validUntil: $url->meta->validUntil,
customSlug: $importShortCode ? $url->shortCode : null,
maxVisits: $url->meta->maxVisits,
domain: $url->domain,
tags: $url->tags,
title: $url->title,
), $relationResolver);
$instance = self::create(ShortUrlCreation::fromRawData($meta), $relationResolver);
$instance->validSince = normalizeOptionalDate($url->meta->validSince);
$instance->validUntil = normalizeOptionalDate($url->meta->validUntil);
$instance->dateCreated = normalizeDate($url->createdAt);
$instance->importSource = $url->source->value;
$instance->importOriginalShortCode = $url->shortCode;
@@ -5,83 +5,104 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use DateTimeInterface;
use Shlinkio\Shlink\Common\ObjectMapper\HostAndPortConverter;
use Shlinkio\Shlink\Common\ObjectMapper\LooseUriConverter;
use Shlinkio\Shlink\Common\ObjectMapper\MappingError;
use Shlinkio\Shlink\Common\ObjectMapper\SubstringConverter;
use Shlinkio\Shlink\Common\ObjectMapper\TagsConverter;
use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Shlinkio\Shlink\Common\normalizeOptionalDate;
use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function str_replace;
use function strpbrk;
use function strtolower;
use function trim;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
final readonly class ShortUrlCreation implements TitleResolutionModelInterface
{
public Chronos|null $validSince;
public Chronos|null $validUntil;
public string|null $customSlug;
public string|null $pathPrefix;
/**
* @param string[] $tags
* @param int<4, max> $shortCodeLength
*/
private function __construct(
public function __construct(
#[LooseUriConverter]
public string $longUrl,
public ShortUrlMode $shortUrlMode,
public Chronos|null $validSince = null,
public Chronos|null $validUntil = null,
public string|null $customSlug = null,
public string|null $pathPrefix = null,
DateTimeInterface|null $validSince = null,
DateTimeInterface|null $validUntil = null,
public ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT,
private bool $multiSegmentSlugsEnabled = false,
string|null $customSlug = null,
string|null $pathPrefix = null,
public int|null $maxVisits = null,
public bool $findIfExists = false,
#[HostAndPortConverter]
public string|null $domain = null,
public int $shortCodeLength = 5,
public int $shortCodeLength = DEFAULT_SHORT_CODES_LENGTH,
public ApiKey|null $apiKey = null,
#[TagsConverter]
public array $tags = [],
#[SubstringConverter(512)]
public string|null $title = null,
public bool $titleWasAutoResolved = false,
public bool $crawlable = false,
public bool $forwardQuery = true,
) {
$this->validSince = normalizeOptionalDate($validSince);
$this->validUntil = normalizeOptionalDate($validUntil);
$this->customSlug = $this->filterAndValidateOptionsRelatedValue($customSlug);
$this->pathPrefix = $this->filterAndValidateOptionsRelatedValue($pathPrefix);
}
/**
* @throws ValidationException
*/
public static function fromRawData(array $data, UrlShortenerOptions $options = new UrlShortenerOptions()): self
private function filterAndValidateOptionsRelatedValue(string|null $value): string|null
{
$inputFilter = ShortUrlInputFilter::forCreation($data, $options);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
if ($value === null) {
return null;
}
return new self(
longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL),
shortUrlMode: $options->mode,
validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)),
validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)),
customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG),
pathPrefix: $inputFilter->getValue(ShortUrlInputFilter::PATH_PREFIX),
maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS),
findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false,
domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN),
shortCodeLength: getOptionalIntFromInputFilter(
$inputFilter,
ShortUrlInputFilter::SHORT_CODE_LENGTH,
) ?? DEFAULT_SHORT_CODES_LENGTH,
apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY),
tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS) ?? [],
title: $inputFilter->getValue(ShortUrlInputFilter::TITLE),
crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE),
forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true,
);
$isLooseMode = $this->shortUrlMode === ShortUrlMode::LOOSE;
$value = $isLooseMode ? strtolower($value) : $value;
$value = $this->multiSegmentSlugsEnabled
? trim(str_replace(' ', '-', $value), '/')
: str_replace([' ', '/'], '-', $value);
// URL gen-delimiter reserved characters, except `/`: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
$reservedChars = ':?#[]@';
if (! $this->multiSegmentSlugsEnabled) {
// Slashes should only be allowed if multi-segment slugs are enabled
$reservedChars .= '/';
}
if (strpbrk($value, $reservedChars) !== false) {
throw MappingError::withBody('URL-reserved characters cannot be used in a custom slug or path prefix');
}
return $value;
}
public function withResolvedTitle(string $title): static
{
// TODO Use clone with once PHP 8.4 is no longer supported
// return clone($this, [
// 'title' => $title,
// 'titleWasAutoResolved' => true,
// ]);
return new self(
longUrl: $this->longUrl,
shortUrlMode: $this->shortUrlMode,
validSince: $this->validSince,
validUntil: $this->validUntil,
shortUrlMode: $this->shortUrlMode,
multiSegmentSlugsEnabled: $this->multiSegmentSlugsEnabled,
customSlug: $this->customSlug,
pathPrefix: $this->pathPrefix,
maxVisits: $this->maxVisits,
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use DateTimeInterface;
use Shlinkio\Shlink\Common\ObjectMapper\TagsConverter;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ObjectMapper\OrderingConverter;
@@ -34,6 +35,7 @@ final readonly class ShortUrlsParams
public int $page = 1,
public int $itemsPerPage = self::DEFAULT_ITEMS_PER_PAGE,
public string|null $searchTerm = null,
#[TagsConverter]
array $tags = [],
#[OrderingConverter]
public Ordering $orderBy = new Ordering(),
@@ -43,6 +45,7 @@ final readonly class ShortUrlsParams
public bool $excludePastValidUntil = false,
public TagsMode $tagsMode = TagsMode::ANY,
public string|null $domain = null,
#[TagsConverter]
array $excludeTags = [],
public TagsMode $excludeTagsMode = TagsMode::ANY,
public string|null $apiKeyName = null,
@@ -12,6 +12,7 @@ use function str_replace;
use function strtolower;
use function trim;
/** @deprecated */
readonly class CustomSlugFilter implements FilterInterface
{
public function __construct(private UrlShortenerOptions $options)
@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use function is_string;
use function strpbrk;
/** @deprecated */
class CustomSlugValidator extends AbstractValidator
{
private const string NOT_STRING = 'NOT_STRING';
@@ -8,9 +8,7 @@ use DateTimeInterface;
use Laminas\Filter;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation\HostAndPortValidator;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function is_string;
@@ -18,16 +16,15 @@ use function preg_match;
use function substr;
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
/** @extends InputFilter<mixed> */
/**
* @extends InputFilter<mixed>
* @deprecated
*/
class ShortUrlInputFilter extends InputFilter
{
// Fields for creation only
public const string SHORT_CODE_LENGTH = 'shortCodeLength';
public const string CUSTOM_SLUG = 'customSlug';
public const string PATH_PREFIX = 'pathPrefix';
public const string FIND_IF_EXISTS = 'findIfExists';
public const string DOMAIN = 'domain';
// Fields for creation and edition
@@ -41,15 +38,6 @@ class ShortUrlInputFilter extends InputFilter
public const string FORWARD_QUERY = 'forwardQuery';
public const string API_KEY = 'apiKey';
public static function forCreation(array $data, UrlShortenerOptions $options): self
{
$instance = new self();
$instance->initializeForCreation($options);
$instance->setData($data);
return $instance;
}
public static function forEdition(array $data): self
{
$instance = new self();
@@ -59,38 +47,6 @@ class ShortUrlInputFilter extends InputFilter
return $instance;
}
private function initializeForCreation(UrlShortenerOptions $options): void
{
// The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value
// is with setContinueIfEmpty(true)
$customSlug = InputFactory::basic(self::CUSTOM_SLUG)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new CustomSlugFilter($options));
$customSlug->getValidatorChain()
->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE,
]))
->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($customSlug);
// The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into
// consideration if multi-segment slugs are enabled or not.
// The only difference is that empty values are allowed here.
$pathPrefix = InputFactory::basic(self::PATH_PREFIX);
$pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options));
$pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options));
$this->add($pathPrefix);
$this->add(InputFactory::numeric(self::SHORT_CODE_LENGTH, min: MIN_SHORT_CODES_LENGTH));
$this->add(InputFactory::boolean(self::FIND_IF_EXISTS));
$domain = InputFactory::basic(self::DOMAIN);
$domain->getValidatorChain()->attach(new HostAndPortValidator());
$this->add($domain);
$this->initializeForEdition(requireLongUrl: true);
}
private function initializeForEdition(bool $requireLongUrl = false): void
{
$longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl);
@@ -131,8 +131,10 @@ class DomainRepositoryTest extends DatabaseTestCase
private function createShortUrl(Domain $domain, ApiKey|null $apiKey = null): ShortUrl
{
return ShortUrl::create(
ShortUrlCreation::fromRawData(
['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'https://foo'],
new ShortUrlCreation(
longUrl: 'https://foo',
domain: $domain->authority,
apiKey: $apiKey,
),
new class ($domain) implements ShortUrlRelationResolverInterface {
public function __construct(private Domain $domain)
@@ -23,7 +23,7 @@ class CrawlableShortCodesQueryTest extends DatabaseTestCase
public function invokingQueryReturnsExpectedResult(): void
{
$createShortUrl = fn (bool $crawlable) => ShortUrl::create(
ShortUrlCreation::fromRawData(['crawlable' => $crawlable, 'longUrl' => 'https://foo.com']),
new ShortUrlCreation('https://foo.com', crawlable: $crawlable),
);
$shortUrl1 = $createShortUrl(true);
@@ -10,7 +10,6 @@ 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;
@@ -62,19 +61,19 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase
{
// 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);
$this->createShortUrls(2, ['validUntil' => Chronos::now()->addDays(1)]);
$this->createShortUrls(3, ['maxVisits' => 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()]);
$this->createShortUrls(3, ['validUntil' => Chronos::now()->subDays(1)]);
// Create some short URLs which reached the max amount of visits
$this->createShortUrls(2, [ShortUrlInputFilter::MAX_VISITS => 3], visitsPerShortUrl: 3);
$this->createShortUrls(2, ['maxVisits' => 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,
'validUntil' => Chronos::now()->subDays(1),
'maxVisits' => 3,
], visitsPerShortUrl: 4);
$this->getEntityManager()->flush();
@@ -85,10 +84,7 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase
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,
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation('https://shlink.io', ...$metadata));
$this->getEntityManager()->persist($shortUrl);
for ($j = 0; $j < $visitsPerShortUrl; $j++) {
@@ -60,7 +60,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
public function findListProperlyFiltersResult(): void
{
$foo = ShortUrl::create(
ShortUrlCreation::fromRawData(['longUrl' => 'https://foo', 'tags' => ['bar']]),
new ShortUrlCreation('https://foo', tags: ['bar']),
$this->relationResolver,
);
$this->getEntityManager()->persist($foo);
@@ -163,30 +163,27 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
#[Test]
public function findListReturnsOnlyThoseWithMatchingTags(): void
{
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo1',
'tags' => ['foo', 'bar'],
]), $this->relationResolver);
$shortUrl1 = ShortUrl::create(
new ShortUrlCreation('https://foo1', tags: ['foo', 'bar']),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo2',
'tags' => ['foo', 'baz'],
]), $this->relationResolver);
$shortUrl2 = ShortUrl::create(
new ShortUrlCreation('https://foo2', tags: ['foo', 'baz']),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo3',
'tags' => ['foo'],
]), $this->relationResolver);
$shortUrl3 = ShortUrl::create(new ShortUrlCreation('https://foo3', tags: ['foo']), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo4',
'tags' => ['bar', 'baz'],
]), $this->relationResolver);
$shortUrl4 = ShortUrl::create(
new ShortUrlCreation('https://foo4', tags: ['bar', 'baz']),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl4);
$shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo5',
'tags' => ['bar', 'baz'],
]), $this->relationResolver);
$shortUrl5 = ShortUrl::create(
new ShortUrlCreation('https://foo5', tags: ['bar', 'baz']),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl5);
$this->getEntityManager()->flush();
@@ -264,20 +261,14 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
#[Test]
public function findListReturnsOnlyThoseWithMatchingDomains(): void
{
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo1',
'domain' => null,
]), $this->relationResolver);
$shortUrl1 = ShortUrl::create(new ShortUrlCreation('https://foo1', domain: null), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo2',
'domain' => null,
]), $this->relationResolver);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation('https://foo2', domain: null), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo3',
'domain' => 'another.com',
]), $this->relationResolver);
$shortUrl3 = ShortUrl::create(
new ShortUrlCreation('https://foo3', domain: 'another.com'),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl3);
$this->getEntityManager()->flush();
@@ -304,25 +295,23 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
#[Test]
public function findListReturnsOnlyThoseWithoutExcludedUrls(): void
{
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo1',
'validUntil' => Chronos::now()->addDays(1)->toAtomString(),
'maxVisits' => 100,
]), $this->relationResolver);
$shortUrl1 = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo1',
validUntil: Chronos::now()->addDays(1),
maxVisits: 100,
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo2',
'validUntil' => Chronos::now()->subDays(1)->toAtomString(),
]), $this->relationResolver);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo2',
validUntil: Chronos::now()->subDays(1),
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo3',
]), $this->relationResolver);
$shortUrl3 = ShortUrl::create(new ShortUrlCreation('https://foo3'), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo4',
'maxVisits' => 3,
]), $this->relationResolver);
$shortUrl4 = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo4',
maxVisits: 3,
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl4);
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty()));
@@ -380,25 +369,13 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
$apiKey3 = ApiKey::create();
$this->getEntityManager()->persist($apiKey3);
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo1',
'apiKey' => $apiKey1,
]), $this->relationResolver);
$shortUrl1 = ShortUrl::create(new ShortUrlCreation('https://foo1', apiKey: $apiKey1), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo2',
'apiKey' => $apiKey1,
]), $this->relationResolver);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation('https://foo2', apiKey: $apiKey1), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo3',
'apiKey' => $apiKey2,
]), $this->relationResolver);
$shortUrl3 = ShortUrl::create(new ShortUrlCreation('https://foo3', apiKey: $apiKey2), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo4',
'apiKey' => $apiKey1,
]), $this->relationResolver);
$shortUrl4 = ShortUrl::create(new ShortUrlCreation('https://foo4', apiKey: $apiKey1), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl4);
$this->getEntityManager()->flush();
@@ -34,18 +34,20 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
#[Test]
public function findOneWithDomainFallbackReturnsProperData(): void
{
$regularOne = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'https://foo']),
);
$regularOne = ShortUrl::create(new ShortUrlCreation(longUrl: 'https://foo', customSlug: 'Foo'));
$this->getEntityManager()->persist($regularOne);
$withDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 'example.com', 'customSlug' => 'domain-short-code', 'longUrl' => 'https://foo'],
$withDomain = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo',
customSlug: 'domain-short-code',
domain: 'example.com',
));
$this->getEntityManager()->persist($withDomain);
$withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'https://foo_with_domain'],
$withDomainDuplicatingRegular = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo_with_domain',
customSlug: 'Foo',
domain: 's.test',
));
$this->getEntityManager()->persist($withDomainDuplicatingRegular);
@@ -103,13 +105,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
#[Test]
public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void
{
$shortUrlWithoutDomain = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'https://foo']),
);
$shortUrlWithoutDomain = ShortUrl::create(new ShortUrlCreation('https://foo', customSlug: 'my-cool-slug'));
$this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'https://foo'],
$shortUrlWithDomain = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo',
customSlug: 'another-slug',
domain: 's.test',
));
$this->getEntityManager()->persist($shortUrlWithDomain);
@@ -133,13 +135,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
public function findOneLooksForShortUrlInProperSetOfTables(): void
{
$shortUrlWithoutDomain = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'https://foo']),
new ShortUrlCreation(longUrl: 'https://foo', customSlug: 'my-cool-slug'),
);
$this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'https://foo'],
));
$shortUrlWithDomain = ShortUrl::create(
new ShortUrlCreation(longUrl: 'https://foo', customSlug: 'another-slug', domain: 's.test'),
);
$this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush();
@@ -159,16 +161,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
#[Test]
public function findOneMatchingReturnsNullForNonExistingShortUrls(): void
{
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'https://foobar'])));
self::assertNull($this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['longUrl' => 'https://foobar', 'tags' => ['foo', 'bar']]),
));
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2020-03-05 20:18:30'),
'customSlug' => 'this_slug_does_not_exist',
'longUrl' => 'https://foobar',
'tags' => ['foo', 'bar'],
])));
self::assertNull($this->repo->findOneMatching(new ShortUrlCreation('https://foobar')));
self::assertNull($this->repo->findOneMatching(new ShortUrlCreation('https://foobar', tags: ['foo', 'bar'])));
self::assertNull($this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foobar',
validSince: Chronos::parse('2020-03-05 20:18:30'),
customSlug: 'this_slug_does_not_exist',
tags: ['foo', 'bar'],
)));
}
#[Test]
@@ -177,31 +177,30 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$start = Chronos::parse('2020-03-05 20:18:30');
$end = Chronos::parse('2021-03-05 20:18:30');
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
), $this->relationResolver);
$shortUrl = ShortUrl::create(
new ShortUrlCreation(longUrl: 'https://foo', validSince: $start, tags: ['foo', 'bar']),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl);
$shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'https://bar']),
);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation(longUrl: 'https://bar', validUntil: $end));
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(
ShortUrlCreation::fromRawData(['validSince' => $start, 'validUntil' => $end, 'longUrl' => 'https://baz']),
new ShortUrlCreation(longUrl: 'https://baz', validSince: $start, validUntil: $end),
);
$this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'https://foo']),
new ShortUrlCreation(longUrl: 'https://foo', validUntil: $end, customSlug: 'custom'),
);
$this->getEntityManager()->persist($shortUrl4);
$shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://foo']));
$shortUrl5 = ShortUrl::create(new ShortUrlCreation(longUrl: 'https://foo', maxVisits: 3));
$this->getEntityManager()->persist($shortUrl5);
$shortUrl6 = ShortUrl::create(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'https://foo']),
new ShortUrlCreation(longUrl: 'https://foo', domain: 's.test'),
);
$this->getEntityManager()->persist($shortUrl6);
@@ -209,41 +208,39 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(
['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
)),
$this->repo->findOneMatching(
new ShortUrlCreation(longUrl: 'https://foo', validSince: $start, tags: ['foo', 'bar']),
),
);
self::assertSame(
$shortUrl2,
$this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'https://bar']),
new ShortUrlCreation(longUrl: 'https://bar', validUntil: $end),
),
);
self::assertSame(
$shortUrl3,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'validUntil' => $end,
'longUrl' => 'https://baz',
])),
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://baz',
validSince: $start,
validUntil: $end,
)),
);
self::assertSame(
$shortUrl4,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'customSlug' => 'custom',
'validUntil' => $end,
'longUrl' => 'https://foo',
])),
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validUntil: $end,
customSlug: 'custom',
)),
);
self::assertSame(
$shortUrl5,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://foo'])),
$this->repo->findOneMatching(new ShortUrlCreation(longUrl: 'https://foo', maxVisits: 3)),
);
self::assertSame(
$shortUrl6,
$this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'https://foo']),
),
$this->repo->findOneMatching(new ShortUrlCreation(longUrl: 'https://foo', domain: 's.test')),
);
}
@@ -252,9 +249,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
{
$start = Chronos::parse('2020-03-05 20:18:30');
$tags = ['foo', 'bar'];
$meta = ShortUrlCreation::fromRawData(
['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'https://foo', 'tags' => $tags],
);
$meta = new ShortUrlCreation('https://foo', validSince: $start, maxVisits: 50, tags: $tags);
$shortUrl1 = ShortUrl::create($meta, $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
@@ -298,106 +293,97 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$adminApiKey = ApiKey::create();
$this->getEntityManager()->persist($adminApiKey);
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'validSince' => $start,
'apiKey' => $apiKey,
'domain' => $rightDomain->authority,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
]), $this->relationResolver);
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
domain: $rightDomain->authority,
apiKey: $apiKey,
tags: ['foo', 'bar'],
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl);
$nonDomainShortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'apiKey' => $apiKey,
'longUrl' => 'https://non-domain',
]), $this->relationResolver);
$nonDomainShortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://non-domain',
apiKey: $apiKey,
), $this->relationResolver);
$this->getEntityManager()->persist($nonDomainShortUrl);
$this->getEntityManager()->flush();
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(
['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
$this->repo->findOneMatching(
new ShortUrlCreation(longUrl: 'https://foo', validSince: $start, tags: ['foo', 'bar']),
),
);
self::assertSame($shortUrl, $this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
apiKey: $apiKey,
tags: ['foo', 'bar'],
)));
self::assertSame($shortUrl, $this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
apiKey: $adminApiKey,
tags: ['foo', 'bar'],
)));
self::assertNull($this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
apiKey: $otherApiKey,
tags: ['foo', 'bar'],
)));
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
domain: $rightDomain->authority,
tags: ['foo', 'bar'],
)),
);
self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'apiKey' => $apiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])));
self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'apiKey' => $adminApiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])));
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'apiKey' => $otherApiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])));
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->authority,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])),
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
domain: $rightDomain->authority,
apiKey: $rightDomainApiKey,
tags: ['foo', 'bar'],
)),
);
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->authority,
'apiKey' => $rightDomainApiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])),
);
self::assertSame(
$shortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->authority,
'apiKey' => $apiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])),
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
domain: $rightDomain->authority,
apiKey: $apiKey,
tags: ['foo', 'bar'],
)),
);
self::assertNull(
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start,
'domain' => $rightDomain->authority,
'apiKey' => $wrongDomainApiKey,
'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'],
])),
$this->repo->findOneMatching(new ShortUrlCreation(
longUrl: 'https://foo',
validSince: $start,
domain: $rightDomain->authority,
apiKey: $wrongDomainApiKey,
tags: ['foo', 'bar'],
)),
);
self::assertSame(
$nonDomainShortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $apiKey,
'longUrl' => 'https://non-domain',
])),
$this->repo->findOneMatching(new ShortUrlCreation('https://non-domain', apiKey: $apiKey)),
);
self::assertSame(
$nonDomainShortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $adminApiKey,
'longUrl' => 'https://non-domain',
])),
$this->repo->findOneMatching(new ShortUrlCreation('https://non-domain', apiKey: $adminApiKey)),
);
self::assertNull(
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $otherApiKey,
'longUrl' => 'https://non-domain',
])),
$this->repo->findOneMatching(new ShortUrlCreation('https://non-domain', apiKey: $otherApiKey)),
);
}
@@ -73,9 +73,8 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags] = array_chunk($names, 3);
$secondUrlTags = [$names[0]];
$metaWithTags = static fn (array $tags, ApiKey|null $apiKey) => ShortUrlCreation::fromRawData(
['longUrl' => 'https://longUrl', 'tags' => $tags, 'apiKey' => $apiKey],
);
$metaWithTags = static fn (array $tags, ApiKey|null $apiKey) =>
new ShortUrlCreation('https://longUrl', apiKey: $apiKey, tags: $tags);
$shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl);
@@ -227,15 +226,14 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags, $secondUrlTags] = array_chunk($names, 3);
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
['apiKey' => $authorApiKey, 'longUrl' => 'https://longUrl', 'tags' => $firstUrlTags],
), $this->relationResolver);
$shortUrl = ShortUrl::create(
new ShortUrlCreation(longUrl: 'https://longUrl', apiKey: $authorApiKey, tags: $firstUrlTags),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl);
$shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData(
['domain' => $domain->authority, 'longUrl' => 'https://longUrl', 'tags' => $secondUrlTags],
),
new ShortUrlCreation('https://longUrl', domain: $domain->authority, tags: $secondUrlTags),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl2);
@@ -7,7 +7,6 @@ namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
@@ -39,21 +38,21 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty()));
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://foo.com',
ShortUrlInputFilter::DOMAIN => 's.test',
ShortUrlInputFilter::CUSTOM_SLUG => 'foo',
]), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
$shortUrl2 = ShortUrl::create(new ShortUrlCreation(
'https://foo.com',
customSlug: 'foo',
domain: 's.test',
), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
$this->getEntityManager()->persist($shortUrl2);
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty()));
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://foo.com',
ShortUrlInputFilter::CUSTOM_SLUG => 'foo',
]), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
$shortUrl3 = ShortUrl::create(new ShortUrlCreation(
'https://foo.com',
customSlug: 'foo',
), new PersistenceShortUrlRelationResolver($this->getEntityManager()));
$this->getEntityManager()->persist($shortUrl3);
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::empty()));
@@ -11,7 +11,6 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount;
use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount;
@@ -205,18 +204,18 @@ class VisitRepositoryTest extends DatabaseTestCase
{
$foo = 'foo';
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => [$foo],
ShortUrlInputFilter::DOMAIN => 'foo.com',
]), $this->relationResolver);
$shortUrl1 = ShortUrl::create(new ShortUrlCreation(
'https://longUrl',
domain: 'foo.com',
tags: [$foo],
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1);
$this->createVisitsForShortUrl($shortUrl1, 6);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => [$foo],
]), $this->relationResolver);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation(
'https://longUrl',
tags: [$foo],
), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 6);
@@ -303,9 +302,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($apiKey1);
$shortUrl = ShortUrl::create(
ShortUrlCreation::fromRawData(
['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'https://longUrl'],
),
new ShortUrlCreation(longUrl: 'https://longUrl', domain: $domain->authority, apiKey: $apiKey1),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl);
@@ -313,16 +310,12 @@ class VisitRepositoryTest extends DatabaseTestCase
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($apiKey2);
$shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'https://longUrl']),
);
$shortUrl2 = ShortUrl::create(new ShortUrlCreation(longUrl: 'https://longUrl', apiKey: $apiKey2));
$this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 5);
$shortUrl3 = ShortUrl::create(
ShortUrlCreation::fromRawData(
['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'https://longUrl'],
),
new ShortUrlCreation('https://longUrl', domain: $domain->authority, apiKey: $apiKey2),
$this->relationResolver,
);
$this->getEntityManager()->persist($shortUrl3);
@@ -391,7 +384,7 @@ class VisitRepositoryTest extends DatabaseTestCase
#[Test]
public function findOrphanVisitsReturnsExpectedResult(): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://longUrl']));
$shortUrl = ShortUrl::withLongUrl('https://longUrl');
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7);
@@ -458,7 +451,7 @@ class VisitRepositoryTest extends DatabaseTestCase
#[Test]
public function countOrphanVisitsReturnsExpectedResult(): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://longUrl']));
$shortUrl = ShortUrl::withLongUrl('https://longUrl');
$this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7);
@@ -582,11 +575,11 @@ class VisitRepositoryTest extends DatabaseTestCase
ApiKey|null $apiKey = null,
int $visitsAmount = 6,
): array {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::API_KEY => $apiKey,
]), $this->relationResolver);
$shortUrl = ShortUrl::create(new ShortUrlCreation(
'https://longUrl',
apiKey: $apiKey,
tags: $tags,
), $this->relationResolver);
$domain = is_string($withDomain) ? $withDomain : 'example.com';
$shortCode = $shortUrl->getShortCode();
$this->getEntityManager()->persist($shortUrl);
@@ -594,11 +587,11 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->createVisitsForShortUrl($shortUrl, $visitsAmount);
if ($withDomain !== false) {
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
'longUrl' => 'https://longUrl',
]));
$shortUrlWithDomain = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
customSlug: $shortCode,
domain: $domain,
));
$this->getEntityManager()->persist($shortUrlWithDomain);
$this->createVisitsForShortUrl($shortUrlWithDomain, 3);
$this->getEntityManager()->flush();
@@ -43,11 +43,11 @@ class PublishingUpdatesGeneratorTest extends TestCase
#[Test, DataProvider('provideMethod')]
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, string|null $title): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'foo',
'longUrl' => 'https://longUrl',
'title' => $title,
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
customSlug: 'foo',
title: $title,
));
$visit = Visit::forValidShortUrl($shortUrl, Visitor::empty());
/** @var Update $update */
@@ -123,11 +123,11 @@ class PublishingUpdatesGeneratorTest extends TestCase
#[Test]
public function shortUrlIsProperlySerializedIntoUpdate(): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'foo',
'longUrl' => 'https://longUrl',
'title' => 'The title',
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
customSlug: 'foo',
title: 'The title',
));
$update = $this->generator->newShortUrlUpdate($shortUrl);
@@ -101,10 +101,7 @@ class NotifyVisitToRabbitMqTest extends TestCase
yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(
ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://foo',
'customSlug' => 'bar',
])),
ShortUrl::create(new ShortUrlCreation('https://foo', customSlug: 'bar')),
$visitor,
),
['newShortUrlVisitUpdate', 'newVisitUpdate'],
@@ -18,7 +18,6 @@ use Shlinkio\Shlink\Core\Matomo\MatomoVisitSender;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
@@ -137,10 +136,7 @@ class MatomoVisitSenderTest extends TestCase
];
yield 'non-orphan visit' => [
Visit::forValidShortUrl(ShortUrl::create(
ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'https://shlink.io',
ShortUrlInputFilter::CUSTOM_SLUG => 'bar',
]),
new ShortUrlCreation('https://shlink.io', customSlug: 'bar'),
), Visitor::empty()),
'http://s2.test/bar',
];
@@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolver;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
@@ -39,9 +38,7 @@ class ShortUrlRedirectionResolverTest extends TestCase
RedirectCondition|null $condition,
string $expectedUrl,
): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://example.com/foo/bar',
]));
$shortUrl = ShortUrl::withLongUrl('https://example.com/foo/bar');
$this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn(
$condition !== null ? [
@@ -9,12 +9,10 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
@@ -42,9 +40,7 @@ class ShortUrlTest extends TestCase
public static function provideInvalidShortUrls(): iterable
{
yield 'with custom slug' => [
ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => 'https://longUrl']),
),
ShortUrl::create(new ShortUrlCreation('https://longUrl', customSlug: 'custom-slug')),
'The short code cannot be regenerated on ShortUrls where a custom slug was provided.',
];
yield 'already persisted' => [
@@ -74,12 +70,15 @@ class ShortUrlTest extends TestCase
)];
}
/**
* @param int<4, max>|null $length
*/
#[Test, DataProvider('provideLengths')]
public function shortCodesHaveExpectedLength(int|null $length, int $expectedLength): void
{
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
[ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'https://longUrl'],
));
$shortUrl = ShortUrl::create(
new ShortUrlCreation('https://longUrl', shortCodeLength: $length ?? DEFAULT_SHORT_CODES_LENGTH),
);
self::assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
}
@@ -98,11 +97,11 @@ class ShortUrlTest extends TestCase
string $expectedPrefix,
int $expectedShortCodeLength,
): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://longUrl',
ShortUrlInputFilter::SHORT_CODE_LENGTH => 5,
ShortUrlInputFilter::PATH_PREFIX => $pathPrefix,
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
pathPrefix: $pathPrefix,
shortCodeLength: 5,
));
$shortCode = $shortUrl->getShortCode();
if (strlen($expectedPrefix) > 0) {
@@ -116,10 +115,7 @@ class ShortUrlTest extends TestCase
{
$range = range(1, 1000); // Use a "big" number to reduce false negatives
$allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
[ShortUrlInputFilter::LONG_URL => 'https://foo'],
new UrlShortenerOptions(mode: $mode),
));
$shortUrl = ShortUrl::create(new ShortUrlCreation('https://foo', shortUrlMode: $mode));
$shortCode = $shortUrl->getShortCode();
return $shortCode === strtolower($shortCode);
@@ -38,10 +38,10 @@ class ShortUrlRedirectionBuilderTest extends TestCase
string|null $extraPath,
bool|null $forwardQuery,
): void {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://example.com/foo/bar?some=thing',
'forwardQuery' => $forwardQuery,
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://example.com/foo/bar?some=thing',
forwardQuery: $forwardQuery ?? true,
));
$this->redirectionResolver->expects($this->once())->method('resolveLongUrl')->with(
$shortUrl,
$request,
@@ -33,11 +33,7 @@ class ShortUrlStringifierTest extends TestCase
public static function provideConfigAndShortUrls(): iterable
{
$shortUrlWithShortCode = fn (string $shortCode, string|null $domain = null) => ShortUrl::create(
ShortUrlCreation::fromRawData([
'longUrl' => 'https://longUrl',
'customSlug' => $shortCode,
'domain' => $domain,
]),
new ShortUrlCreation('https://longUrl', customSlug: $shortCode, domain: $domain),
);
yield 'no default domain' => ['', 'http', '', $shortUrlWithShortCode('foo'), 'http:/foo'];
@@ -38,7 +38,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
#[Test]
public function dataIsReturnedAsIsWhenResolvingTitlesIsDisabled(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$this->httpClient->expects($this->never())->method('request');
$this->logger->expects($this->never())->method('warning');
@@ -50,10 +50,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
#[Test]
public function dataIsReturnedAsIsWhenItAlreadyHasTitle(): void
{
$data = ShortUrlCreation::fromRawData([
'longUrl' => self::LONG_URL,
'title' => 'foo',
]);
$data = new ShortUrlCreation(self::LONG_URL, title: 'foo');
$this->httpClient->expects($this->never())->method('request');
$this->logger->expects($this->never())->method('warning');
@@ -65,7 +62,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
#[Test]
public function dataIsReturnedAsIsWhenFetchingFails(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$this->expectRequestToBeCalled()->willThrowException(new Exception('Error'));
$this->logger->expects($this->never())->method('warning');
@@ -77,7 +74,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
#[Test]
public function dataIsReturnedAsIsWhenResponseIsNotHtml(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$this->expectRequestToBeCalled()->willReturn(new JsonResponse(['foo' => 'bar']));
$this->logger->expects($this->never())->method('warning');
@@ -89,7 +86,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
#[Test]
public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void
{
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$this->expectRequestToBeCalled()->willReturn($this->respWithoutTitle());
$this->logger->expects($this->never())->method('warning');
@@ -113,7 +110,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
$this->logger->expects($this->never())->method('warning');
}
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data);
self::assertNotSame($data, $result);
@@ -125,7 +122,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
{
$this->expectRequestToBeCalled()->willReturn($this->respWithTitle('text/html'));
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data);
self::assertSame($data, $result);
@@ -144,7 +141,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
extraContent: $extraContent,
));
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: true)->processTitle($data);
self::assertNotSame($data, $result);
@@ -180,7 +177,7 @@ class ShortUrlTitleResolutionHelperTest extends TestCase
},
));
$data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]);
$data = new ShortUrlCreation(self::LONG_URL);
$result = $this->helper(autoResolveTitles: true, iconvEnabled: $iconvEnabled)->processTitle($data);
self::assertNotSame($data, $result);
@@ -4,106 +4,30 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ShortUrl\Model;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use stdClass;
use function str_pad;
use const STR_PAD_BOTH;
class ShortUrlCreationTest extends TestCase
{
#[Test, DataProvider('provideInvalidData')]
public function exceptionIsThrownIfProvidedDataIsInvalid(array $data): void
{
$this->expectException(ValidationException::class);
ShortUrlCreation::fromRawData($data);
}
public static function provideInvalidData(): iterable
{
yield [[]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::VALID_SINCE => '',
ShortUrlInputFilter::VALID_UNTIL => '',
ShortUrlInputFilter::CUSTOM_SLUG => 'foobar',
ShortUrlInputFilter::MAX_VISITS => 'invalid',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::VALID_SINCE => '2017',
ShortUrlInputFilter::MAX_VISITS => 5,
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::VALID_SINCE => new stdClass(),
ShortUrlInputFilter::VALID_UNTIL => 'foo',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::VALID_UNTIL => 500,
ShortUrlInputFilter::DOMAIN => 4,
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::SHORT_CODE_LENGTH => 3,
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => '',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => 'foo?some=param',
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'https://foo',
ShortUrlInputFilter::CUSTOM_SLUG => ' ',
]];
yield [[
ShortUrlInputFilter::LONG_URL => [],
]];
yield [[
ShortUrlInputFilter::LONG_URL => null,
]];
yield [[
ShortUrlInputFilter::LONG_URL => 'missing_schema',
]];
}
#[Test, DataProvider('provideCustomSlugs')]
public function properlyCreatedInstanceReturnsValues(
public function properlyCreatesInstancesWithCustomSlug(
string $customSlug,
string $expectedSlug,
bool $multiSegmentEnabled = false,
ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT,
): void {
$creation = ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2015-01-01')->toAtomString(),
'customSlug' => $customSlug,
'longUrl' => 'https://longUrl',
], new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentEnabled, mode: $shortUrlMode));
self::assertTrue($creation->hasValidSince());
self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince);
self::assertFalse($creation->hasValidUntil());
self::assertNull($creation->validUntil);
$creation = new ShortUrlCreation(
longUrl: 'https://longUrl',
shortUrlMode: $shortUrlMode,
multiSegmentSlugsEnabled: $multiSegmentEnabled,
customSlug: $customSlug,
);
self::assertTrue($creation->hasCustomSlug());
self::assertEquals($expectedSlug, $creation->customSlug);
self::assertFalse($creation->hasMaxVisits());
self::assertNull($creation->maxVisits);
}
public static function provideCustomSlugs(): iterable
@@ -130,61 +54,4 @@ class ShortUrlCreationTest extends TestCase
yield ['谷歌', '谷歌'];
yield ['гугл', 'гугл'];
}
#[Test, DataProvider('provideValidLongUrls')]
public function supportsDifferentTypesOfSchemas(string $longUrl): void
{
$creation = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
self::assertEquals($longUrl, $creation->longUrl);
}
public static function provideValidLongUrls(): iterable
{
yield 'mailto' => ['mailto:foo@example.com'];
yield 'file' => ['file:///foo/bar'];
yield 'https' => ['https://example.com'];
yield 'deeplink' => ['shlink://some/path'];
}
#[Test, DataProvider('provideTitles')]
public function titleIsCroppedIfTooLong(string|null $title, string|null $expectedTitle): void
{
$creation = ShortUrlCreation::fromRawData([
'title' => $title,
'longUrl' => 'https://longUrl',
]);
self::assertEquals($expectedTitle, $creation->title);
}
public static function provideTitles(): iterable
{
yield [null, null];
yield ['foo', 'foo'];
yield [str_pad('bar', 600, ' ', STR_PAD_BOTH), 'bar'];
yield [str_pad('', 511, 'a'), str_pad('', 511, 'a')];
yield [str_pad('', 512, 'b'), str_pad('', 512, 'b')];
yield [str_pad('', 513, 'c'), str_pad('', 512, 'c')];
yield [str_pad('', 600, 'd'), str_pad('', 512, 'd')];
yield [str_pad('', 800, 'e'), str_pad('', 512, 'e')];
}
#[Test, DataProvider('provideDomains')]
public function emptyDomainIsDiscarded(string|null $domain, string|null $expectedDomain): void
{
$creation = ShortUrlCreation::fromRawData([
'domain' => $domain,
'longUrl' => 'https://longUrl',
]);
self::assertSame($expectedDomain, $creation->domain);
}
public static function provideDomains(): iterable
{
yield 'null domain' => [null, null];
yield 'empty domain' => ['', null];
yield 'trimmable domain' => [' ', null];
yield 'valid domain' => ['s.test', 's.test'];
}
}
@@ -124,9 +124,7 @@ class ShortUrlResolverTest extends TestCase
$now = Chronos::now();
yield 'maxVisits reached' => [(function () {
$shortUrl = ShortUrl::create(
ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://longUrl']),
);
$shortUrl = ShortUrl::create(new ShortUrlCreation('https://longUrl', maxVisits: 3));
$shortUrl->setVisits(new ArrayCollection(array_map(
fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()),
range(0, 4),
@@ -134,18 +132,20 @@ class ShortUrlResolverTest extends TestCase
return $shortUrl;
})()];
yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now->addMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'],
yield 'future validSince' => [ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
validSince: $now->addMonths(1),
))];
yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData(
['validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'],
yield 'past validUntil' => [ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
validUntil: $now->subMonths(1),
))];
yield 'mixed' => [(function () use ($now) {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'maxVisits' => 3,
'validUntil' => $now->subMonths(1)->toAtomString(),
'longUrl' => 'https://longUrl',
]));
$shortUrl = ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
validUntil: $now->subMonths(1),
maxVisits: 3,
));
$shortUrl->setVisits(new ArrayCollection(array_map(
fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()),
range(0, 4),
@@ -42,18 +42,16 @@ class ShortUrlDataTransformerTest extends TestCase
'validUntil' => null,
'maxVisits' => null,
]];
yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([
'maxVisits' => $maxVisits,
'longUrl' => 'https://longUrl',
])), [
yield 'max visits only' => [ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
maxVisits: $maxVisits,
)), [
'validSince' => null,
'validUntil' => null,
'maxVisits' => $maxVisits,
]];
yield 'max visits and valid since' => [
ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => 'https://longUrl'],
)),
ShortUrl::create(new ShortUrlCreation('https://longUrl', validSince: $now, maxVisits: $maxVisits)),
[
'validSince' => $now->toAtomString(),
'validUntil' => null,
@@ -61,9 +59,7 @@ class ShortUrlDataTransformerTest extends TestCase
],
];
yield 'both dates' => [
ShortUrl::create(ShortUrlCreation::fromRawData(
['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => 'https://longUrl'],
)),
ShortUrl::create(new ShortUrlCreation('https://longUrl', validSince: $now, validUntil: $now->subDays(10))),
[
'validSince' => $now->toAtomString(),
'validUntil' => $now->subDays(10)->toAtomString(),
@@ -71,12 +67,12 @@ class ShortUrlDataTransformerTest extends TestCase
],
];
yield 'everything' => [
ShortUrl::create(ShortUrlCreation::fromRawData([
'validSince' => $now,
'validUntil' => $now->subDays(5),
'maxVisits' => $maxVisits,
'longUrl' => 'https://longUrl',
])),
ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
validSince: $now,
validUntil: $now->subDays(5),
maxVisits: $maxVisits,
)),
[
'validSince' => $now->toAtomString(),
'validUntil' => $now->subDays(5)->toAtomString(),
@@ -88,10 +84,10 @@ class ShortUrlDataTransformerTest extends TestCase
#[Test]
public function properTagsAreReturned(): void
{
['tags' => $tags] = $this->transformer->transform(ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://longUrl',
'tags' => ['foo', 'bar', 'baz'],
])));
['tags' => $tags] = $this->transformer->transform(ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://longUrl',
tags: ['foo', 'bar', 'baz'],
)));
self::assertEquals(['foo', 'bar', 'baz'], $tags);
}
}
+32 -46
View File
@@ -57,7 +57,7 @@ class UrlShortenerTest extends TestCase
public function urlIsProperlyShortened(bool $expectDispatchError, callable $dispatchBehavior): void
{
$longUrl = 'http://foobar.com/12345/hello?foo=bar';
$meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]);
$meta = new ShortUrlCreation($longUrl);
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
$meta,
)->willReturnArgument(0);
@@ -86,9 +86,7 @@ class UrlShortenerTest extends TestCase
#[Test]
public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void
{
$meta = ShortUrlCreation::fromRawData(
['customSlug' => 'custom-slug', 'longUrl' => 'http://foobar.com/12345/hello?foo=bar'],
);
$meta = new ShortUrlCreation(longUrl: 'http://foobar.com/12345/hello?foo=bar', customSlug: 'custom-slug');
$this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false);
$this->titleResolutionHelper->expects($this->once())->method('processTitle')->with(
@@ -116,54 +114,42 @@ class UrlShortenerTest extends TestCase
{
$url = 'http://foo.com';
yield [ShortUrlCreation::fromRawData(['findIfExists' => true, 'longUrl' => $url]), ShortUrl::withLongUrl(
$url,
)];
yield [ShortUrlCreation::fromRawData(
['findIfExists' => true, 'customSlug' => 'foo', 'longUrl' => $url],
), ShortUrl::withLongUrl($url)];
yield [new ShortUrlCreation($url, findIfExists: true), ShortUrl::withLongUrl($url)];
yield [new ShortUrlCreation($url, customSlug: 'foo', findIfExists: true), ShortUrl::withLongUrl($url)];
yield [
ShortUrlCreation::fromRawData(['findIfExists' => true, 'longUrl' => $url, 'tags' => ['foo', 'bar']]),
ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => $url, 'tags' => ['foo', 'bar']])),
new ShortUrlCreation($url, findIfExists: true, tags: ['foo', 'bar']),
ShortUrl::create(new ShortUrlCreation($url, tags: ['foo', 'bar'])),
];
yield [
ShortUrlCreation::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]),
ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => $url])),
new ShortUrlCreation($url, maxVisits: 3, findIfExists: true),
ShortUrl::create(new ShortUrlCreation($url, maxVisits: 3)),
];
yield [
ShortUrlCreation::fromRawData(
['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url],
new ShortUrlCreation($url, validSince: Chronos::parse('2017-01-01'), findIfExists: true),
ShortUrl::create(new ShortUrlCreation($url, validSince: Chronos::parse('2017-01-01'))),
];
yield [
new ShortUrlCreation($url, validUntil: Chronos::parse('2017-01-01'), findIfExists: true),
ShortUrl::create(new ShortUrlCreation($url, validUntil: Chronos::parse('2017-01-01'))),
];
yield [
new ShortUrlCreation($url, findIfExists: true, domain: 'example.com'),
ShortUrl::create(new ShortUrlCreation($url, domain: 'example.com')),
];
yield [
new ShortUrlCreation(
longUrl: $url,
validUntil: Chronos::parse('2017-01-01'),
maxVisits: 4,
findIfExists: true,
tags: ['baz', 'foo', 'bar'],
),
ShortUrl::create(
ShortUrlCreation::fromRawData(['validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url]),
),
];
yield [
ShortUrlCreation::fromRawData(
['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url],
),
ShortUrl::create(
ShortUrlCreation::fromRawData(['validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url]),
),
];
yield [
ShortUrlCreation::fromRawData(['findIfExists' => true, 'domain' => 'example.com', 'longUrl' => $url]),
ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 'example.com', 'longUrl' => $url])),
];
yield [
ShortUrlCreation::fromRawData([
'findIfExists' => true,
'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4,
'longUrl' => $url,
'tags' => ['baz', 'foo', 'bar'],
]),
ShortUrl::create(ShortUrlCreation::fromRawData([
'validUntil' => Chronos::parse('2017-01-01'),
'maxVisits' => 4,
'longUrl' => $url,
'tags' => ['foo', 'bar', 'baz'],
])),
ShortUrl::create(new ShortUrlCreation(
longUrl: $url,
validUntil: Chronos::parse('2017-01-01'),
maxVisits: 4,
tags: ['foo', 'bar', 'baz'],
)),
];
}
}
@@ -74,11 +74,13 @@ return [
ShortUrl\UrlShortener::class,
ShortUrlDataTransformer::class,
Config\Options\UrlShortenerOptions::class,
TreeMapper::class,
],
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
ShortUrl\UrlShortener::class,
ShortUrlDataTransformer::class,
Config\Options\UrlShortenerOptions::class,
TreeMapper::class,
],
Action\ShortUrl\EditShortUrlAction::class => [ShortUrl\ShortUrlService::class, ShortUrlDataTransformer::class],
Action\ShortUrl\DeleteShortUrlAction::class => [ShortUrl\DeleteShortUrlService::class],
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use CuyZ\Valinor\Mapper\TreeMapper;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -19,7 +20,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlDataTransformerInterface $transformer,
protected readonly UrlShortenerOptions $urlShortenerOptions,
private readonly UrlShortenerOptions $urlShortenerOptions,
private readonly TreeMapper $treeMapper,
) {
}
@@ -31,6 +33,15 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
return new JsonResponse($this->transformer->transform($result->shortUrl));
}
protected function mapShortUrlCreation(array $payload): ShortUrlCreation
{
return $this->treeMapper->map(ShortUrlCreation::class, [
...$payload,
'shortUrlMode' => $this->urlShortenerOptions->mode,
'multiSegmentSlugsEnabled' => $this->urlShortenerOptions->multiSegmentSlugsEnabled,
]);
}
/**
* @throws ValidationException
*/
@@ -5,9 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class CreateShortUrlAction extends AbstractCreateShortUrlAction
@@ -15,14 +13,13 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
protected const string ROUTE_PATH = '/short-urls';
protected const array ROUTE_ALLOWED_METHODS = [self::METHOD_POST];
/**
* @throws ValidationException
*/
protected function buildShortUrlData(Request $request): ShortUrlCreation
{
$payload = (array) $request->getParsedBody();
$payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request);
$body = (array) $request->getParsedBody();
return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions);
return $this->mapShortUrlCreation([
...$body,
'apiKey' => AuthenticationMiddleware::apiKeyFromRequest($request),
]);
}
}
@@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Middleware\ShortUrl\OverrideDomainMiddleware;
class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
{
@@ -20,11 +20,11 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
$longUrl = $query['longUrl'] ?? null;
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
return ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::API_KEY => $apiKey,
return $this->mapShortUrlCreation([
'longUrl' => $longUrl,
'apiKey' => $apiKey,
// This will usually be null, unless this API key enforces one specific domain
ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN),
], $this->urlShortenerOptions);
'domain' => OverrideDomainMiddleware::domainFromRequest($request),
]);
}
}
@@ -10,13 +10,14 @@ use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class OverrideDomainMiddleware implements MiddlewareInterface
{
public function __construct(private DomainServiceInterface $domainService)
private const string REQUEST_ATTRIBUTE = 'domain';
public function __construct(private readonly DomainServiceInterface $domainService)
{
}
@@ -34,11 +35,16 @@ class OverrideDomainMiddleware implements MiddlewareInterface
if ($requestMethod === RequestMethodInterface::METHOD_POST) {
/** @var array $payload */
$payload = $request->getParsedBody();
$payload[ShortUrlInputFilter::DOMAIN] = $domain->authority;
$payload[self::REQUEST_ATTRIBUTE] = $domain->authority;
return $handler->handle($request->withParsedBody($payload));
}
return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority));
return $handler->handle($request->withAttribute(self::REQUEST_ATTRIBUTE, $domain->authority));
}
public static function domainFromRequest(ServerRequestInterface $request): string|null
{
return $request->getAttribute(self::REQUEST_ATTRIBUTE);
}
}
@@ -14,6 +14,9 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function array_map;
use function range;
use function sprintf;
use function str_pad;
use const STR_PAD_BOTH;
class CreateShortUrlTest extends ApiTestCase
{
@@ -29,6 +32,23 @@ class CreateShortUrlTest extends ApiTestCase
}
}
#[Test, DataProvider('provideValidLongUrls')]
public function lonUrlSupportsDifferentTypesOfSchemas(string $longUrl): void
{
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $longUrl]);
self::assertEquals(self::STATUS_OK, $statusCode);
self::assertEquals($longUrl, $payload['longUrl']);
}
public static function provideValidLongUrls(): iterable
{
yield 'mailto' => ['mailto:foo@example.com'];
yield 'file' => ['file:///foo/bar'];
yield 'https' => ['https://example.com'];
yield 'deeplink' => ['shlink://some/path'];
}
#[Test]
public function createsNewShortUrlWithCustomSlug(): void
{
@@ -228,7 +248,7 @@ class CreateShortUrlTest extends ApiTestCase
}
#[Test, DataProvider('provideInvalidArgumentApiVersions')]
public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void
public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version): void
{
$resp = $this->callApiWithKey(
self::METHOD_POST,
@@ -239,19 +259,19 @@ class CreateShortUrlTest extends ApiTestCase
self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals($expectedType, $payload['type']);
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
self::assertEquals('Provided data is not valid', $payload['detail']);
self::assertEquals('Invalid data', $payload['title']);
}
public static function provideInvalidArgumentApiVersions(): iterable
{
yield 'missing long url v2' => [[], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'empty long url v2' => [['longUrl' => null], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'https://shlink.io/api/error/invalid-data'];
yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3', 'https://shlink.io/api/error/invalid-data'];
yield 'missing long url v2' => [[], '2'];
yield 'missing long url v3' => [[], '3'];
yield 'empty long url v2' => [['longUrl' => null], '2'];
yield 'empty long url v3' => [['longUrl' => ' '], '3'];
yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2'];
yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3'];
}
#[Test]
@@ -314,6 +334,26 @@ class CreateShortUrlTest extends ApiTestCase
self::assertNull($payload['title']);
}
#[Test, DataProvider('provideTitles')]
public function titleIsCroppedIfTooLong(string $title, string $expectedTitle): void
{
[$statusCode, ['title' => $returnedTitle]] = $this->createShortUrl(['title' => $title]);
self::assertEquals(self::STATUS_OK, $statusCode);
self::assertEquals($expectedTitle, $returnedTitle);
}
public static function provideTitles(): iterable
{
yield ['foo', 'foo'];
yield [str_pad('bar', 600, ' ', STR_PAD_BOTH), 'bar'];
yield [str_pad('', 511, 'a'), str_pad('', 511, 'a')];
yield [str_pad('', 512, 'b'), str_pad('', 512, 'b')];
yield [str_pad('', 513, 'c'), str_pad('', 512, 'c')];
yield [str_pad('', 600, 'd'), str_pad('', 512, 'd')];
yield [str_pad('', 800, 'e'), str_pad('', 512, 'e')];
}
#[Test]
#[TestWith([null])]
#[TestWith(['my-custom-slug'])]
@@ -329,6 +369,23 @@ class CreateShortUrlTest extends ApiTestCase
self::assertStringStartsWith('foo-b--ar-baz', $payload['shortCode']);
}
#[Test]
#[TestWith(['localhost:80000'])]
#[TestWith(['127.0.0.1'])]
#[TestWith(['???/&%$&'])]
public function failsToCreateShortUrlWithInvalidDomain(string $domain): void
{
[$statusCode, $payload] = $this->createShortUrl(['domain' => $domain]);
self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
self::assertEquals('Provided data is not valid', $payload['detail']);
self::assertEquals('Invalid data', $payload['title']);
}
/**
* @return array{int, array}
*/
@@ -29,61 +29,61 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf
$authorApiKey = $this->getReference('author_api_key');
$abcShortUrl = $this->setShortUrlDate(
ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'abc123',
'apiKey' => $authorApiKey,
'longUrl' => 'https://shlink.io',
'tags' => ['foo'],
'title' => 'My cool title',
'crawlable' => true,
'maxVisits' => 2,
]), $relationResolver),
ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://shlink.io',
customSlug: 'abc123',
maxVisits: 2,
apiKey: $authorApiKey,
tags: ['foo'],
title: 'My cool title',
crawlable: true,
), $relationResolver),
'2018-05-01',
);
$manager->persist($abcShortUrl);
$defShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2020-05-01'),
'customSlug' => 'def456',
'apiKey' => $authorApiKey,
'longUrl' =>
$defShortUrl = $this->setShortUrlDate(ShortUrl::create(new ShortUrlCreation(
longUrl:
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
'tags' => ['foo', 'bar'],
]), $relationResolver), '2019-01-01 00:00:10');
validSince: Chronos::parse('2020-05-01'),
customSlug: 'def456',
apiKey: $authorApiKey,
tags: ['foo', 'bar'],
), $relationResolver), '2019-01-01 00:00:10');
$manager->persist($defShortUrl);
$customShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'custom',
'maxVisits' => 2,
'apiKey' => $authorApiKey,
'longUrl' => 'https://shlink.io',
'crawlable' => true,
'forwardQuery' => false,
])), '2019-01-01 00:00:20');
$customShortUrl = $this->setShortUrlDate(ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://shlink.io',
customSlug: 'custom',
maxVisits: 2,
apiKey: $authorApiKey,
crawlable: true,
forwardQuery: false,
)), '2019-01-01 00:00:20');
$manager->persist($customShortUrl);
$ghiShortUrl = $this->setShortUrlDate(
ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => 'ghi789',
'longUrl' => 'https://shlink.io/documentation/',
'validUntil' => Chronos::parse('2020-05-01'), // In the past
])),
ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://shlink.io/documentation/',
validUntil: Chronos::parse('2020-05-01'), // In the past
customSlug: 'ghi789',
)),
'2018-05-01',
);
$manager->persist($ghiShortUrl);
$withDomainDuplicatingShortCode = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([
'domain' => 'example.com',
'customSlug' => 'ghi789',
'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-'
$withDomainDuplicatingShortCode = $this->setShortUrlDate(ShortUrl::create(new ShortUrlCreation(
longUrl: 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-'
. 'source-software-projects/',
'tags' => ['foo'],
]), $relationResolver), '2019-01-01 00:00:30');
customSlug: 'ghi789',
domain: 'example.com',
tags: ['foo'],
), $relationResolver), '2019-01-01 00:00:30');
$manager->persist($withDomainDuplicatingShortCode);
$withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain', 'longUrl' => 'https://google.com'],
)), '2018-10-20');
$withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::create(
new ShortUrlCreation('https://google.com', customSlug: 'custom-with-domain', domain: 'some-domain.com'),
), '2018-10-20');
$manager->persist($withDomainAndSlugShortUrl);
$manager->flush();
@@ -5,15 +5,13 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use CuyZ\Valinor\MapperBuilder;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
@@ -33,27 +31,42 @@ class CreateShortUrlActionTest extends TestCase
$this->urlShortener = $this->createMock(UrlShortener::class);
$this->transformer = $this->createMock(ShortUrlDataTransformerInterface::class);
$this->action = new CreateShortUrlAction($this->urlShortener, $this->transformer, new UrlShortenerOptions());
$this->action = new CreateShortUrlAction(
$this->urlShortener,
$this->transformer,
new UrlShortenerOptions(),
new MapperBuilder()->mapper(),
);
}
#[Test]
public function properShortcodeConversionReturnsData(): void
{
$now = Chronos::now()->microsecond(0);
$apiKey = ApiKey::create();
$shortUrl = ShortUrl::createFake();
$expectedMeta = $body = [
'longUrl' => 'http://www.domain.com/foo/bar',
'validSince' => Chronos::now()->toAtomString(),
'validUntil' => Chronos::now()->toAtomString(),
'customSlug' => 'foo-bar-baz',
'maxVisits' => 50,
'findIfExists' => true,
'domain' => 'my-domain.com',
$expectedCreation = new ShortUrlCreation(
longUrl: 'http://www.domain.com/foo/bar',
validSince: $now,
validUntil: $now,
customSlug: 'foo-bar-baz',
maxVisits: 50,
findIfExists: true,
domain: 'my-domain.com',
apiKey: $apiKey,
);
$body = [
'longUrl' => $expectedCreation->longUrl,
'validSince' => $now->toAtomString(),
'validUntil' => $now->toAtomString(),
'customSlug' => $expectedCreation->customSlug,
'maxVisits' => $expectedCreation->maxVisits,
'findIfExists' => $expectedCreation->findIfExists,
'domain' => $expectedCreation->domain,
];
$expectedMeta['apiKey'] = $apiKey;
$this->urlShortener->expects($this->once())->method('shorten')->with(
ShortUrlCreation::fromRawData($expectedMeta),
$expectedCreation,
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->transformer->expects($this->once())->method('transform')->with($shortUrl)->willReturn(
['shortUrl' => 'stringified_short_url'],
@@ -68,27 +81,4 @@ class CreateShortUrlActionTest extends TestCase
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('stringified_short_url', $payload['shortUrl']);
}
#[Test, DataProvider('provideInvalidDomains')]
public function anInvalidDomainReturnsError(string $domain): void
{
$this->urlShortener->expects($this->never())->method('shorten');
$this->transformer->expects($this->never())->method('transform');
$request = (new ServerRequest())->withParsedBody([
'longUrl' => 'http://www.domain.com/foo/bar',
'domain' => $domain,
])->withAttribute(ApiKey::class, ApiKey::create());
$this->expectException(ValidationException::class);
$this->action->handle($request);
}
public static function provideInvalidDomains(): iterable
{
yield ['localhost:80000'];
yield ['127.0.0.1'];
yield ['???/&%$&'];
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
use CuyZ\Valinor\MapperBuilder;
use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
@@ -32,6 +33,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase
$this->urlShortener,
$transformer,
new UrlShortenerOptions(),
new MapperBuilder()->mapper(),
);
}
@@ -40,11 +42,11 @@ class SingleStepCreateShortUrlActionTest extends TestCase
{
$apiKey = ApiKey::create();
$request = (new ServerRequest())->withQueryParams([
$request = new ServerRequest()->withQueryParams([
'longUrl' => 'http://foobar.com',
])->withAttribute(ApiKey::class, $apiKey);
$this->urlShortener->expects($this->once())->method('shorten')->with(
ShortUrlCreation::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']),
new ShortUrlCreation('http://foobar.com', apiKey: $apiKey),
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$resp = $this->action->handle($request);