Updated logic to generate random short codes, increasing entropy

This commit is contained in:
Alejandro Celaya 2019-10-11 09:14:25 +02:00
parent c8d950e04d
commit 2f09ff456c
13 changed files with 91 additions and 149 deletions

View file

@ -33,6 +33,7 @@
"ocramius/proxy-manager": "~2.2.2", "ocramius/proxy-manager": "~2.2.2",
"phly/phly-event-dispatcher": "^1.0", "phly/phly-event-dispatcher": "^1.0",
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.0", "shlinkio/shlink-common": "^2.0",
"shlinkio/shlink-event-dispatcher": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.0",
"shlinkio/shlink-installer": "^2.1", "shlinkio/shlink-installer": "^2.1",

View file

@ -2,8 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Common\env;
return [ return [
@ -13,7 +11,6 @@ return [
'schema' => env('SHORTENED_URL_SCHEMA', 'http'), 'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => env('SHORTENED_URL_HOSTNAME'), 'hostname' => env('SHORTENED_URL_HOSTNAME'),
], ],
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortenerOptions::DEFAULT_CHARS),
'validate_url' => true, 'validate_url' => true,
'not_found_short_url' => [ 'not_found_short_url' => [
'enable_redirection' => false, 'enable_redirection' => false,

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config; namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -18,6 +17,7 @@ use function str_shuffle;
class GenerateCharsetCommand extends Command class GenerateCharsetCommand extends Command
{ {
public const NAME = 'config:generate-charset'; public const NAME = 'config:generate-charset';
private const DEFAULT_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
protected function configure(): void protected function configure(): void
{ {
@ -26,14 +26,14 @@ class GenerateCharsetCommand extends Command
->setDescription(sprintf( ->setDescription(sprintf(
'[DEPRECATED] Generates a character set sample just by shuffling the default one, "%s". ' '[DEPRECATED] Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable', . 'Then it can be set in the SHORTCODE_CHARS environment variable',
UrlShortenerOptions::DEFAULT_CHARS self::DEFAULT_CHARS
)) ))
->setHelp('<fg=red;options=bold>This command is deprecated. Better leave shlink generate the charset.</>'); ->setHelp('<fg=red;options=bold>This command is deprecated. Better leave shlink generate the charset.</>');
} }
protected function execute(InputInterface $input, OutputInterface $output): ?int protected function execute(InputInterface $input, OutputInterface $output): ?int
{ {
$charSet = str_shuffle(UrlShortenerOptions::DEFAULT_CHARS); $charSet = str_shuffle(self::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet)); (new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet));
return ExitCodes::EXIT_SUCCESS; return ExitCodes::EXIT_SUCCESS;
} }

View file

@ -20,6 +20,11 @@ use Symfony\Component\Console\Tester\CommandTester;
class GenerateShortUrlCommandTest extends TestCase class GenerateShortUrlCommandTest extends TestCase
{ {
private const DOMAIN_CONFIG = [
'schema' => 'http',
'hostname' => 'foo.com',
];
/** @var CommandTester */ /** @var CommandTester */
private $commandTester; private $commandTester;
/** @var ObjectProphecy */ /** @var ObjectProphecy */
@ -28,10 +33,7 @@ class GenerateShortUrlCommandTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), [ $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
'schema' => 'http',
'hostname' => 'foo.com',
]);
$app = new Application(); $app = new Application();
$app->add($command); $app->add($command);
$this->commandTester = new CommandTester($command); $this->commandTester = new CommandTester($command);
@ -40,9 +42,8 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */ /** @test */
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{ {
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn( $shortUrl = new ShortUrl('');
(new ShortUrl(''))->setShortCode('abc123') $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn($shortUrl);
);
$this->commandTester->execute([ $this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar', 'longUrl' => 'http://domain.com/foo/bar',
@ -51,7 +52,7 @@ class GenerateShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); $this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('http://foo.com/abc123', $output); $this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
$urlToShortCode->shouldHaveBeenCalledOnce(); $urlToShortCode->shouldHaveBeenCalledOnce();
} }
@ -86,6 +87,7 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */ /** @test */
public function properlyProcessesProvidedTags(): void public function properlyProcessesProvidedTags(): void
{ {
$shortUrl = new ShortUrl('');
$urlToShortCode = $this->urlShortener->urlToShortCode( $urlToShortCode = $this->urlShortener->urlToShortCode(
Argument::type(UriInterface::class), Argument::type(UriInterface::class),
Argument::that(function (array $tags) { Argument::that(function (array $tags) {
@ -93,7 +95,7 @@ class GenerateShortUrlCommandTest extends TestCase
return $tags; return $tags;
}), }),
Argument::cetera() Argument::cetera()
)->willReturn((new ShortUrl(''))->setShortCode('abc123')); )->willReturn($shortUrl);
$this->commandTester->execute([ $this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar', 'longUrl' => 'http://domain.com/foo/bar',
@ -102,7 +104,7 @@ class GenerateShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); $this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString('http://foo.com/abc123', $output); $this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
$urlToShortCode->shouldHaveBeenCalledOnce(); $urlToShortCode->shouldHaveBeenCalledOnce();
} }
} }

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PUGX\Shortid\Factory as ShortIdFactory;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface; use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver; use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
@ -20,6 +21,8 @@ use function Functional\invoke;
class ShortUrl extends AbstractEntity class ShortUrl extends AbstractEntity
{ {
private const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
/** @var string */ /** @var string */
private $longUrl; private $longUrl;
/** @var string */ /** @var string */
@ -53,10 +56,15 @@ class ShortUrl extends AbstractEntity
$this->validSince = $meta->getValidSince(); $this->validSince = $meta->getValidSince();
$this->validUntil = $meta->getValidUntil(); $this->validUntil = $meta->getValidUntil();
$this->maxVisits = $meta->getMaxVisits(); $this->maxVisits = $meta->getMaxVisits();
$this->shortCode = $meta->getCustomSlug() ?? ''; // TODO logic to calculate short code should be passed somehow $this->shortCode = $meta->getCustomSlug() ?? $this->generateShortCode();
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain()); $this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
} }
private function generateShortCode(): string
{
return (new ShortIdFactory())->generate(6, self::BASE62)->serialize();
}
public function getLongUrl(): string public function getLongUrl(): string
{ {
return $this->longUrl; return $this->longUrl;
@ -67,13 +75,6 @@ class ShortUrl extends AbstractEntity
return $this->shortCode; return $this->shortCode;
} }
// TODO Short code is currently calculated based on the ID, so a setter is needed
public function setShortCode(string $shortCode): self
{
$this->shortCode = $shortCode;
return $this;
}
public function getDateCreated(): Chronos public function getDateCreated(): Chronos
{ {
return $this->dateCreated; return $this->dateCreated;

View file

@ -8,26 +8,12 @@ use Zend\Stdlib\AbstractOptions;
class UrlShortenerOptions extends AbstractOptions class UrlShortenerOptions extends AbstractOptions
{ {
public const DEFAULT_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
// phpcs:disable // phpcs:disable
protected $__strictMode__ = false; protected $__strictMode__ = false;
// phpcs:enable // phpcs:enable
private $shortcodeChars = self::DEFAULT_CHARS;
private $validateUrl = true; private $validateUrl = true;
public function getChars(): string
{
return $this->shortcodeChars;
}
protected function setShortcodeChars(string $shortcodeChars): self
{
$this->shortcodeChars = empty($shortcodeChars) ? self::DEFAULT_CHARS : $shortcodeChars;
return $this;
}
public function isUrlValidationEnabled(): bool public function isUrlValidationEnabled(): bool
{ {
return $this->validateUrl; return $this->validateUrl;

View file

@ -24,17 +24,11 @@ use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Throwable; use Throwable;
use function array_reduce; use function array_reduce;
use function floor;
use function fmod;
use function preg_match;
use function strlen;
class UrlShortener implements UrlShortenerInterface class UrlShortener implements UrlShortenerInterface
{ {
use TagManagerTrait; use TagManagerTrait;
private const ID_INCREMENT = 200000;
/** @var ClientInterface */ /** @var ClientInterface */
private $httpClient; private $httpClient;
/** @var EntityManagerInterface */ /** @var EntityManagerInterface */
@ -77,16 +71,8 @@ class UrlShortener implements UrlShortenerInterface
// First, create the short URL with an empty short code // First, create the short URL with an empty short code
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em)); $shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
$this->em->persist($shortUrl);
$this->em->flush();
// Generate the short code and persist it if no custom slug was provided
if (! $meta->hasCustomSlug()) {
// TODO Somehow provide the logic to calculate the shortCode to avoid the need of a setter
$shortCode = $this->convertAutoincrementIdToShortCode((float) $shortUrl->getId());
$shortUrl->setShortCode($shortCode);
}
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->em->persist($shortUrl);
$this->em->flush(); $this->em->flush();
$this->em->commit(); $this->em->commit();
@ -155,36 +141,12 @@ class UrlShortener implements UrlShortenerInterface
} }
} }
private function convertAutoincrementIdToShortCode(float $id): string
{
$id += self::ID_INCREMENT; // Increment the Id so that the generated shortcode is not too short
$chars = $this->options->getChars();
$length = strlen($chars);
$code = '';
while ($id > 0) {
// Determine the value of the next higher character in the short code and prepend it
$code = $chars[(int) fmod($id, $length)] . $code;
$id = floor($id / $length);
}
return $chars[(int) $id] . $code;
}
/** /**
* @throws InvalidShortCodeException * @throws InvalidShortCodeException
* @throws EntityDoesNotExistException * @throws EntityDoesNotExistException
*/ */
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
{ {
$chars = $this->options->getChars();
// Validate short code format
if (! preg_match('|[' . $chars . ']+|', $shortCode)) {
throw InvalidShortCodeException::fromCharset($shortCode, $chars);
}
/** @var ShortUrlRepository $shortUrlRepo */ /** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain); $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);

View file

@ -37,37 +37,41 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function findOneByShortCodeReturnsProperData(): void public function findOneByShortCodeReturnsProperData(): void
{ {
$regularOne = new ShortUrl('foo'); $regularOne = new ShortUrl('foo', ShortUrlMeta::createFromParams(null, null, 'foo'));
$regularOne->setShortCode('foo');
$this->getEntityManager()->persist($regularOne); $this->getEntityManager()->persist($regularOne);
$notYetValid = new ShortUrl('bar', ShortUrlMeta::createFromParams(Chronos::now()->addMonth())); $notYetValid = new ShortUrl(
$notYetValid->setShortCode('bar_very_long_text'); 'bar',
ShortUrlMeta::createFromParams(Chronos::now()->addMonth(), null, 'bar_very_long_text')
);
$this->getEntityManager()->persist($notYetValid); $this->getEntityManager()->persist($notYetValid);
$expired = new ShortUrl('expired', ShortUrlMeta::createFromParams(null, Chronos::now()->subMonth())); $expired = new ShortUrl('expired', ShortUrlMeta::createFromParams(null, Chronos::now()->subMonth(), 'expired'));
$expired->setShortCode('expired');
$this->getEntityManager()->persist($expired); $this->getEntityManager()->persist($expired);
$allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::createFromRawData(['maxVisits' => 3])); $allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::createFromRawData([
'maxVisits' => 3,
'customSlug' => 'baz',
]));
$visits = []; $visits = [];
for ($i = 0; $i < 3; $i++) { for ($i = 0; $i < 3; $i++) {
$visit = new Visit($allVisitsComplete, Visitor::emptyInstance()); $visit = new Visit($allVisitsComplete, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
$visits[] = $visit; $visits[] = $visit;
} }
$allVisitsComplete->setShortCode('baz') $allVisitsComplete->setVisits(new ArrayCollection($visits));
->setVisits(new ArrayCollection($visits));
$this->getEntityManager()->persist($allVisitsComplete); $this->getEntityManager()->persist($allVisitsComplete);
$withDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData(['domain' => 'example.com'])); $withDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData([
$withDomain->setShortCode('domain-short-code'); 'domain' => 'example.com',
'customSlug' => 'domain-short-code',
]));
$this->getEntityManager()->persist($withDomain); $this->getEntityManager()->persist($withDomain);
$withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::createFromRawData([ $withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::createFromRawData([
'domain' => 'doma.in', 'domain' => 'doma.in',
'customSlug' => 'foo',
])); ]));
$withDomainDuplicatingRegular->setShortCode('foo');
$this->getEntityManager()->persist($withDomainDuplicatingRegular); $this->getEntityManager()->persist($withDomainDuplicatingRegular);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@ -96,9 +100,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
{ {
$count = 5; $count = 5;
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
$this->getEntityManager()->persist( $this->getEntityManager()->persist(new ShortUrl((string) $i));
(new ShortUrl((string) $i))->setShortCode((string) $i)
);
} }
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@ -112,19 +114,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($tag); $this->getEntityManager()->persist($tag);
$foo = new ShortUrl('foo'); $foo = new ShortUrl('foo');
$foo->setShortCode('foo') $foo->setTags(new ArrayCollection([$tag]));
->setTags(new ArrayCollection([$tag]));
$this->getEntityManager()->persist($foo); $this->getEntityManager()->persist($foo);
$bar = new ShortUrl('bar'); $bar = new ShortUrl('bar');
$visit = new Visit($bar, Visitor::emptyInstance()); $visit = new Visit($bar, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
$bar->setShortCode('bar_very_long_text') $bar->setVisits(new ArrayCollection([$visit]));
->setVisits(new ArrayCollection([$visit]));
$this->getEntityManager()->persist($bar); $this->getEntityManager()->persist($bar);
$foo2 = new ShortUrl('foo_2'); $foo2 = new ShortUrl('foo_2');
$foo2->setShortCode('foo_2');
$this->getEntityManager()->persist($foo2); $this->getEntityManager()->persist($foo2);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@ -155,9 +154,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
{ {
$urls = ['a', 'z', 'c', 'b']; $urls = ['a', 'z', 'c', 'b'];
foreach ($urls as $url) { foreach ($urls as $url) {
$this->getEntityManager()->persist( $this->getEntityManager()->persist(new ShortUrl($url));
(new ShortUrl($url))->setShortCode($url)
);
} }
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@ -174,13 +171,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
/** @test */ /** @test */
public function slugIsInUseLooksForShortUrlInProperSetOfTables(): void public function slugIsInUseLooksForShortUrlInProperSetOfTables(): void
{ {
$shortUrlWithoutDomain = (new ShortUrl('foo'))->setShortCode('my-cool-slug'); $shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData(['customSlug' => 'my-cool-slug']));
$this->getEntityManager()->persist($shortUrlWithoutDomain); $this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = (new ShortUrl( $shortUrlWithDomain = new ShortUrl(
'foo', 'foo',
ShortUrlMeta::createFromRawData(['domain' => 'doma.in']) ShortUrlMeta::createFromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug'])
))->setShortCode('another-slug'); );
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();

View file

@ -19,20 +19,21 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlService;
use function Functional\map; use function Functional\map;
use function range; use function range;
use function sprintf;
class DeleteShortUrlServiceTest extends TestCase class DeleteShortUrlServiceTest extends TestCase
{ {
/** @var DeleteShortUrlService */
private $service;
/** @var ObjectProphecy */ /** @var ObjectProphecy */
private $em; private $em;
/** @var string */
private $shortCode;
public function setUp(): void public function setUp(): void
{ {
$shortUrl = (new ShortUrl(''))->setShortCode('abc123') $shortUrl = (new ShortUrl(''))->setVisits(new ArrayCollection(map(range(0, 10), function () {
->setVisits(new ArrayCollection(map(range(0, 10), function () { return new Visit(new ShortUrl(''), Visitor::emptyInstance());
return new Visit(new ShortUrl(''), Visitor::emptyInstance()); })));
}))); $this->shortCode = $shortUrl->getShortCode();
$this->em = $this->prophesize(EntityManagerInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class);
@ -42,55 +43,56 @@ class DeleteShortUrlServiceTest extends TestCase
} }
/** @test */ /** @test */
public function deleteByShortCodeThrowsExceptionWhenThresholdIsReached() public function deleteByShortCodeThrowsExceptionWhenThresholdIsReached(): void
{ {
$service = $this->createService(); $service = $this->createService();
$this->expectException(DeleteShortUrlException::class); $this->expectException(DeleteShortUrlException::class);
$this->expectExceptionMessage( $this->expectExceptionMessage(sprintf(
'Impossible to delete short URL with short code "abc123" since it has more than "5" visits.' 'Impossible to delete short URL with short code "%s" since it has more than "5" visits.',
); $this->shortCode
));
$service->deleteByShortCode('abc123'); $service->deleteByShortCode($this->shortCode);
} }
/** @test */ /** @test */
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButExplicitlyIgnored() public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButExplicitlyIgnored(): void
{ {
$service = $this->createService(); $service = $this->createService();
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123', true); $service->deleteByShortCode($this->shortCode, true);
$remove->shouldHaveBeenCalledOnce(); $remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce();
} }
/** @test */ /** @test */
public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButCheckIsDisabled() public function deleteByShortCodeDeletesUrlWhenThresholdIsReachedButCheckIsDisabled(): void
{ {
$service = $this->createService(false); $service = $this->createService(false);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123'); $service->deleteByShortCode($this->shortCode);
$remove->shouldHaveBeenCalledOnce(); $remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce();
} }
/** @test */ /** @test */
public function deleteByShortCodeDeletesUrlWhenThresholdIsNotReached() public function deleteByShortCodeDeletesUrlWhenThresholdIsNotReached(): void
{ {
$service = $this->createService(true, 100); $service = $this->createService(true, 100);
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$service->deleteByShortCode('abc123'); $service->deleteByShortCode($this->shortCode);
$remove->shouldHaveBeenCalledOnce(); $remove->shouldHaveBeenCalledOnce();
$flush->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce();

View file

@ -17,7 +17,6 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException; use Shlinkio\Shlink\Core\Exception\RuntimeException;
@ -74,13 +73,13 @@ class UrlShortenerTest extends TestCase
/** @test */ /** @test */
public function urlIsProperlyShortened(): void public function urlIsProperlyShortened(): void
{ {
// 10 -> 0Q1Y
$shortUrl = $this->urlShortener->urlToShortCode( $shortUrl = $this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'), new Uri('http://foobar.com/12345/hello?foo=bar'),
[], [],
ShortUrlMeta::createEmpty() ShortUrlMeta::createEmpty()
); );
$this->assertEquals('0Q1Y', $shortUrl->getShortCode());
$this->assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl());
} }
/** @test */ /** @test */
@ -243,9 +242,8 @@ class UrlShortenerTest extends TestCase
/** @test */ /** @test */
public function shortCodeIsProperlyParsed(): void public function shortCodeIsProperlyParsed(): void
{ {
$shortCode = '12C1c';
$shortUrl = new ShortUrl('expected_url'); $shortUrl = new ShortUrl('expected_url');
$shortUrl->setShortCode($shortCode); $shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class); $repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl); $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
@ -254,11 +252,4 @@ class UrlShortenerTest extends TestCase
$url = $this->urlShortener->shortCodeToUrl($shortCode); $url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertSame($shortUrl, $url); $this->assertSame($shortUrl, $url);
} }
/** @test */
public function invalidCharSetThrowsException(): void
{
$this->expectException(InvalidShortCodeException::class);
$this->urlShortener->shortCodeToUrl('&/(');
}
} }

View file

@ -90,7 +90,7 @@ class CreateShortUrlActionTest extends ApiTestCase
$this->assertEquals(self::STATUS_OK, $statusCode); $this->assertEquals(self::STATUS_OK, $statusCode);
// Request to the short URL will return a 404 since ist' not valid yet // Request to the short URL will return a 404 since it's not valid yet
$lastResp = $this->callShortUrl($shortCode); $lastResp = $this->callShortUrl($shortCode);
$this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); $this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode());
} }

View file

@ -20,13 +20,15 @@ class ShortUrlsFixture extends AbstractFixture
*/ */
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$abcShortUrl = $this->setShortUrlDate(new ShortUrl('https://shlink.io'))->setShortCode('abc123'); $abcShortUrl = $this->setShortUrlDate(
new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123']))
);
$manager->persist($abcShortUrl); $manager->persist($abcShortUrl);
$defShortUrl = $this->setShortUrlDate(new ShortUrl( $defShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
ShortUrlMeta::createFromParams(Chronos::parse('2020-05-01')) ShortUrlMeta::createFromParams(Chronos::parse('2020-05-01'), null, 'def456')
))->setShortCode('def456'); ));
$manager->persist($defShortUrl); $manager->persist($defShortUrl);
$customShortUrl = $this->setShortUrlDate(new ShortUrl( $customShortUrl = $this->setShortUrlDate(new ShortUrl(
@ -37,14 +39,14 @@ class ShortUrlsFixture extends AbstractFixture
$withDomainShortUrl = $this->setShortUrlDate(new ShortUrl( $withDomainShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/', 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
ShortUrlMeta::createFromRawData(['domain' => 'example.com']) ShortUrlMeta::createFromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789'])
))->setShortCode('ghi789'); ));
$manager->persist($withDomainShortUrl); $manager->persist($withDomainShortUrl);
$withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl( $withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl(
'https://google.com', 'https://google.com',
ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com']) ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain'])
))->setShortCode('custom-with-domain'); ));
$manager->persist($withDomainAndSlugShortUrl); $manager->persist($withDomainAndSlugShortUrl);
$manager->flush(); $manager->flush();

View file

@ -22,6 +22,11 @@ use function strpos;
class CreateShortUrlActionTest extends TestCase class CreateShortUrlActionTest extends TestCase
{ {
private const DOMAIN_CONFIG = [
'schema' => 'http',
'hostname' => 'foo.com',
];
/** @var CreateShortUrlAction */ /** @var CreateShortUrlAction */
private $action; private $action;
/** @var ObjectProphecy */ /** @var ObjectProphecy */
@ -30,10 +35,7 @@ class CreateShortUrlActionTest extends TestCase
public function setUp(): void public function setUp(): void
{ {
$this->urlShortener = $this->prophesize(UrlShortener::class); $this->urlShortener = $this->prophesize(UrlShortener::class);
$this->action = new CreateShortUrlAction($this->urlShortener->reveal(), [ $this->action = new CreateShortUrlAction($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
'schema' => 'http',
'hostname' => 'foo.com',
]);
} }
/** @test */ /** @test */
@ -46,10 +48,9 @@ class CreateShortUrlActionTest extends TestCase
/** @test */ /** @test */
public function properShortcodeConversionReturnsData(): void public function properShortcodeConversionReturnsData(): void
{ {
$shortUrl = new ShortUrl('');
$this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera())
->willReturn( ->willReturn($shortUrl)
(new ShortUrl(''))->setShortCode('abc123')
)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$request = (new ServerRequest())->withParsedBody([ $request = (new ServerRequest())->withParsedBody([
@ -57,7 +58,7 @@ class CreateShortUrlActionTest extends TestCase
]); ]);
$response = $this->action->handle($request); $response = $this->action->handle($request);
$this->assertEquals(200, $response->getStatusCode()); $this->assertEquals(200, $response->getStatusCode());
$this->assertTrue(strpos($response->getBody()->getContents(), 'http://foo.com/abc123') > 0); $this->assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0);
} }
/** @test */ /** @test */