Merge pull request #630 from acelaya-forks/feature/fetch-not-visitable-url

Feature/fetch not visitable url
This commit is contained in:
Alejandro Celaya 2020-01-26 20:00:47 +01:00 committed by GitHub
commit 4fb2c64fa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 329 additions and 152 deletions

View file

@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected.
* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset.
* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled.
## 2.0.2 - 2020-01-12

View file

@ -55,7 +55,7 @@ return [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@ -20,12 +20,12 @@ class ResolveUrlCommand extends Command
{
public const NAME = 'short-url:parse';
private UrlShortenerInterface $urlShortener;
private ShortUrlResolverInterface $urlResolver;
public function __construct(UrlShortenerInterface $urlShortener)
public function __construct(ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->urlShortener = $urlShortener;
$this->urlResolver = $urlResolver;
}
protected function configure(): void
@ -58,7 +58,7 @@ class ResolveUrlCommand extends Command
$domain = $input->getOption('domain');
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
$url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) {

View file

@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -20,12 +20,12 @@ use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
{
private CommandTester $commandTester;
private ObjectProphecy $urlShortener;
private ObjectProphecy $urlResolver;
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new ResolveUrlCommand($this->urlShortener->reveal());
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$command = new ResolveUrlCommand($this->urlResolver->reveal());
$app = new Application();
$app->add($command);
@ -38,8 +38,8 @@ class ResolveUrlCommandTest extends TestCase
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
@ -50,7 +50,7 @@ class ResolveUrlCommandTest extends TestCase
public function incorrectShortCodeOutputsErrorMessage(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)
$this->urlResolver->shortCodeToShortUrl($shortCode, null)
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
->shouldBeCalledOnce();

View file

@ -30,6 +30,7 @@ return [
Service\VisitService::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
Util\UrlValidator::class => ConfigAbstractFactory::class,
@ -56,22 +57,27 @@ return [
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Util\UrlValidator::class => ['httpClient'],
Action\RedirectAction::class => [
Service\UrlShortener::class,
Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\UrlShortener::class,
Service\ShortUrl\ShortUrlResolver::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
Action\QrCodeAction::class => [
RouterInterface::class,
Service\ShortUrl\ShortUrlResolver::class,
'Logger_Shlink',
],
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
],

View file

@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function array_key_exists;
@ -25,18 +25,18 @@ use function GuzzleHttp\Psr7\parse_query;
abstract class AbstractTrackingAction implements MiddlewareInterface
{
private UrlShortenerInterface $urlShortener;
private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker;
private AppOptions $appOptions;
private LoggerInterface $logger;
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions,
?LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger();
@ -50,7 +50,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
$disableTrackParam = $this->appOptions->getDisableTrackParam();
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
$url = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
// Track visit to this short code
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode;
use Mezzio\Router\Exception\RuntimeException;
use Mezzio\Router\RouterInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@ -15,7 +14,7 @@ use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeAction implements MiddlewareInterface
{
@ -24,27 +23,19 @@ class QrCodeAction implements MiddlewareInterface
private const MAX_SIZE = 1000;
private RouterInterface $router;
private UrlShortenerInterface $urlShortener;
private ShortUrlResolverInterface $urlResolver;
private LoggerInterface $logger;
public function __construct(
RouterInterface $router,
UrlShortenerInterface $urlShortener,
ShortUrlResolverInterface $urlResolver,
?LoggerInterface $logger = null
) {
$this->router = $router;
$this->urlShortener = $urlShortener;
$this->urlResolver = $urlResolver;
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
* @throws \InvalidArgumentException
* @throws RuntimeException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
{
// Make sure the short URL exists for this short code
@ -52,7 +43,7 @@ class QrCodeAction implements MiddlewareInterface
$domain = $request->getUri()->getAuthority();
try {
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, $domain);
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request);

View file

@ -135,7 +135,6 @@ class ShortUrl extends AbstractEntity
/**
* @param Collection|Visit[] $visits
* @return ShortUrl
* @internal
*/
public function setVisits(Collection $visits): self
@ -149,9 +148,25 @@ class ShortUrl extends AbstractEntity
return $this->maxVisits;
}
public function maxVisitsReached(): bool
public function isEnabled(): bool
{
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
$maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
if ($maxVisitsReached) {
return false;
}
$now = Chronos::now();
$beforeValidSince = $this->validSince !== null && $this->validSince->gt($now);
if ($beforeValidSince) {
return false;
}
$afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now);
if ($afterValidUntil) {
return false;
}
return true;
}
public function toString(array $domainConfig): string
@ -186,12 +201,10 @@ class ShortUrl extends AbstractEntity
}
$shortUrlTags = invoke($this->getTags(), '__toString');
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
return count($shortUrlTags) === count($tags) && array_reduce(
$tags,
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
true,
);
return $hasAllTags;
}
}

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange;
@ -146,8 +145,6 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
LEFT JOIN s.domain AS d
WHERE s.shortCode = :shortCode
AND (s.validSince <= :now OR s.validSince IS NULL)
AND (s.validUntil >= :now OR s.validUntil IS NULL)
AND (s.domain IS NULL OR d.authority = :domain)
ORDER BY s.domain {$ordering}
DQL;
@ -156,7 +153,6 @@ DQL;
$query->setMaxResults(1)
->setParameters([
'shortCode' => $shortCode,
'now' => Chronos::now(),
'domain' => $domain,
]);
@ -166,9 +162,7 @@ DQL;
// * The short URL matching the short code but without any domain, or
// * No short URL at all
/** @var ShortUrl|null $shortUrl */
$shortUrl = $query->getOneOrNullResult();
return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
return $query->getOneOrNullResult();
}
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortUrlResolver implements ShortUrlResolverInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl
{
$shortUrl = $this->shortCodeToShortUrl($shortCode, $domain);
if (! $shortUrl->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
interface ShortUrlResolverInterface
{
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToEnabledShortUrl(string $shortCode, ?string $domain = null): ShortUrl;
}

View file

@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@ -124,20 +123,4 @@ class UrlShortener implements UrlShortenerInterface
$this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated);
}
}
/**
* @throws ShortUrlNotFoundException
* @fixme Move this method to a different service
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) {
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;
}
}

View file

@ -8,7 +8,6 @@ use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
interface UrlShortenerInterface
@ -19,9 +18,4 @@ interface UrlShortenerInterface
* @throws InvalidUrlException
*/
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
/**
* @throws ShortUrlNotFoundException
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl;
}

View file

@ -41,26 +41,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$regularOne = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
$this->getEntityManager()->persist($regularOne);
$notYetValid = new ShortUrl('bar', ShortUrlMeta::fromRawData(
['validSince' => Chronos::now()->addMonth(), 'customSlug' => 'bar_very_long_text'],
));
$this->getEntityManager()->persist($notYetValid);
$expired = new ShortUrl('expired', ShortUrlMeta::fromRawData(
['validUntil' => Chronos::now()->subMonth(), 'customSlug' => 'expired'],
));
$this->getEntityManager()->persist($expired);
$allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::fromRawData(['maxVisits' => 3, 'customSlug' => 'baz']));
$visits = [];
for ($i = 0; $i < 3; $i++) {
$visit = new Visit($allVisitsComplete, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit);
$visits[] = $visit;
}
$allVisitsComplete->setVisits(new ArrayCollection($visits));
$this->getEntityManager()->persist($allVisitsComplete);
$withDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(
['domain' => 'example.com', 'customSlug' => 'domain-short-code'],
));
@ -87,9 +67,6 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertNull($this->repo->findOneByShortCode('invalid'));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com'));
$this->assertNull($this->repo->findOneByShortCode($notYetValid->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($expired->getShortCode()));
$this->assertNull($this->repo->findOneByShortCode($allVisitsComplete->getShortCode()));
}
/** @test */

View file

@ -13,22 +13,22 @@ use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Action\PixelAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
class PixelActionTest extends TestCase
{
private PixelAction $action;
private ObjectProphecy $urlShortener;
private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker;
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->visitTracker = $this->prophesize(VisitsTracker::class);
$this->action = new PixelAction(
$this->urlShortener->reveal(),
$this->urlResolver->reveal(),
$this->visitTracker->reveal(),
new AppOptions(),
);
@ -38,7 +38,7 @@ class PixelActionTest extends TestCase
public function imageIsReturned(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(
new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();

View file

@ -15,29 +15,29 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeActionTest extends TestCase
{
private QrCodeAction $action;
private ObjectProphecy $urlShortener;
private ObjectProphecy $urlResolver;
public function setUp(): void
{
$router = $this->prophesize(RouterInterface::class);
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->action = new QrCodeAction($router->reveal(), $this->urlShortener->reveal());
$this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
}
/** @test */
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -50,8 +50,8 @@ class QrCodeActionTest extends TestCase
public function anInvalidShortCodeWillReturnNotFoundResponse(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -64,8 +64,8 @@ class QrCodeActionTest extends TestCase
public function aCorrectRequestReturnsTheQrCodeResponse(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn(new ShortUrl(''))
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$resp = $this->action->process(

View file

@ -14,24 +14,24 @@ use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use function array_key_exists;
class RedirectActionTest extends TestCase
{
private RedirectAction $action;
private ObjectProphecy $urlShortener;
private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker;
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->visitTracker = $this->prophesize(VisitsTracker::class);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->visitTracker = $this->prophesize(VisitsTrackerInterface::class);
$this->action = new RedirectAction(
$this->urlShortener->reveal(),
$this->urlResolver->reveal(),
$this->visitTracker->reveal(),
new Options\AppOptions(['disableTrackParam' => 'foobar']),
);
@ -45,7 +45,7 @@ class RedirectActionTest extends TestCase
{
$shortCode = 'abc123';
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl);
$shortCodeToUrl = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
});
@ -74,8 +74,8 @@ class RedirectActionTest extends TestCase
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$handler = $this->prophesize(RequestHandlerInterface::class);

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\ShortUrl;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver;
use function Functional\map;
use function range;
class ShortUrlResolverTest extends TestCase
{
private ShortUrlResolver $urlResolver;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->urlResolver = new ShortUrlResolver($this->em->reveal());
}
/** @test */
public function shortCodeIsProperlyParsed(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToShortUrl($shortCode);
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function exceptionIsThrownIfShortcodeIsNotFound(): void
{
$shortCode = 'abc123';
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn(null);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$findOneByShortCode->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->shortCodeToShortUrl($shortCode);
}
/** @test */
public function shortCodeToEnabledShortUrlProperlyParsesShortCode(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->shortCodeToEnabledShortUrl($shortCode);
$this->assertSame($shortUrl, $result);
$findOneByShortCode->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideDisabledShortUrls
*/
public function shortCodeToEnabledShortUrlThrowsExceptionIfUrlIsNotEnabled(ShortUrl $shortUrl): void
{
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$findOneByShortCode = $repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
$findOneByShortCode->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->urlResolver->shortCodeToEnabledShortUrl($shortCode);
}
public function provideDisabledShortUrls(): iterable
{
$now = Chronos::now();
yield 'maxVisits reached' => [(function () {
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => 3]));
$shortUrl->setVisits(new ArrayCollection(map(
range(0, 4),
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
)));
return $shortUrl;
})()];
yield 'future validSince' => [new ShortUrl('', ShortUrlMeta::fromRawData([
'validSince' => $now->addMonth()->toAtomString(),
]))];
yield 'past validUntil' => [new ShortUrl('', ShortUrlMeta::fromRawData([
'validUntil' => $now->subMonth()->toAtomString(),
]))];
yield 'mixed' => [(function () use ($now) {
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData([
'maxVisits' => 3,
'validUntil' => $now->subMonth()->toAtomString(),
]));
$shortUrl->setVisits(new ArrayCollection(map(
range(0, 4),
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
)));
return $shortUrl;
})()];
}
}

View file

@ -19,7 +19,6 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
@ -260,18 +259,4 @@ class UrlShortenerTest extends TestCase
$findExisting->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
/** @test */
public function shortCodeIsProperlyParsed(): void
{
$shortUrl = new ShortUrl('expected_url');
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertSame($shortUrl, $url);
}
}

View file

@ -57,7 +57,10 @@ return [
],
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\ResolveShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Action\ShortUrl\ResolveShortUrlAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain',
],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
Action\ShortUrl\ListShortUrlsAction::class => [
Service\ShortUrlService::class,

View file

@ -4,12 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use InvalidArgumentException;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@ -18,29 +17,26 @@ class ResolveShortUrlAction extends AbstractRestAction
protected const ROUTE_PATH = '/short-urls/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private UrlShortenerInterface $urlShortener;
private ShortUrlResolverInterface $urlResolver;
private array $domainConfig;
public function __construct(
UrlShortenerInterface $urlShortener,
ShortUrlResolverInterface $urlResolver,
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlShortener = $urlShortener;
$this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig;
}
/**
* @throws InvalidArgumentException
*/
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$domain = $request->getQueryParams()['domain'] ?? null;
$transformer = new ShortUrlDataTransformer($this->domainConfig);
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
$url = $this->urlResolver->shortCodeToShortUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url));
}
}

View file

@ -18,7 +18,7 @@ class EditShortUrlActionTest extends ApiTestCase
/**
* @test
* @dataProvider provideDisablingMeta
* @dataProvider provideMeta
*/
public function metadataCanBeReset(array $meta): void
{
@ -30,11 +30,9 @@ class EditShortUrlActionTest extends ApiTestCase
'maxVisits' => null,
];
// Setting meta that disables the URL should not let it be visited
$editWithProvidedMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $meta]);
$metaAfterEditing = $this->findShortUrlMetaByShortCode($shortCode);
// Resetting all meta should allow the URL to be visitable again
$editWithResetMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [
RequestOptions::JSON => $resetMeta,
]);
@ -46,7 +44,7 @@ class EditShortUrlActionTest extends ApiTestCase
self::assertArraySubset($meta, $metaAfterEditing);
}
public function provideDisablingMeta(): iterable
public function provideMeta(): iterable
{
$now = Chronos::now();

View file

@ -4,10 +4,42 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
class ResolveShortUrlActionTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideDisabledMeta
*/
public function shortUrlIsProperlyResolvedEvenWhenNotEnabled(array $disabledMeta): void
{
$shortCode = 'abc123';
$url = sprintf('/short-urls/%s', $shortCode);
$this->callShortUrl($shortCode);
$editResp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $disabledMeta]);
$visitResp = $this->callShortUrl($shortCode);
$fetchResp = $this->callApiWithKey(self::METHOD_GET, $url);
$this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
$this->assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode());
$this->assertEquals(self::STATUS_OK, $fetchResp->getStatusCode());
}
public function provideDisabledMeta(): iterable
{
$now = Chronos::now();
yield 'future validSince' => [['validSince' => $now->addMonth()->toAtomString()]];
yield 'past validUntil' => [['validUntil' => $now->subMonth()->toAtomString()]];
yield 'maxVisits reached' => [['maxVisits' => 1]];
}
/** @test */
public function tryingToResolveInvalidUrlReturnsNotFoundError(): void
{

View file

@ -8,7 +8,7 @@ use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Rest\Action\ShortUrl\ResolveShortUrlAction;
use function strpos;
@ -16,19 +16,19 @@ use function strpos;
class ResolveShortUrlActionTest extends TestCase
{
private ResolveShortUrlAction $action;
private ObjectProphecy $urlShortener;
private ObjectProphecy $urlResolver;
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->action = new ResolveShortUrlAction($this->urlShortener->reveal(), []);
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
$this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), []);
}
/** @test */
public function correctShortCodeReturnsSuccess(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn(
$this->urlResolver->shortCodeToShortUrl($shortCode, null)->willReturn(
new ShortUrl('http://domain.com/foo/bar'),
)->shouldBeCalledOnce();