diff --git a/bin/wkhtmltoimage b/bin/wkhtmltoimage new file mode 100755 index 00000000..8acfa45a Binary files /dev/null and b/bin/wkhtmltoimage differ diff --git a/composer.json b/composer.json index 88c74604..923d6f32 100644 --- a/composer.json +++ b/composer.json @@ -28,10 +28,12 @@ "guzzlehttp/guzzle": "^6.2", "symfony/console": "^3.0", "symfony/process": "^3.0", + "symfony/filesystem": "^3.0", "firebase/php-jwt": "^4.0", "monolog/monolog": "^1.21", "theorchard/monolog-cascade": "^0.4", - "endroid/qrcode": "^1.7" + "endroid/qrcode": "^1.7", + "mikehaertl/phpwkhtmltopdf": "^2.2" }, "require-dev": { "phpunit/phpunit": "^5.0", diff --git a/config/autoload/phpwkhtmltopdf.global.php b/config/autoload/phpwkhtmltopdf.global.php new file mode 100644 index 00000000..f7c29f16 --- /dev/null +++ b/config/autoload/phpwkhtmltopdf.global.php @@ -0,0 +1,11 @@ + [ + 'images' => [ + 'binary' => 'bin/wkhtmltoimage', + 'type' => 'jpg', + ], + ], + +]; diff --git a/config/autoload/preview-generation.global.php b/config/autoload/preview-generation.global.php new file mode 100644 index 00000000..b4f14da3 --- /dev/null +++ b/config/autoload/preview-generation.global.php @@ -0,0 +1,8 @@ + [ + 'files_location' => 'data/cache', + ], + +]; diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 31c4e460..8bd88607 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -10,6 +10,7 @@ return [ Command\Shortcode\ResolveUrlCommand::class, Command\Shortcode\ListShortcodesCommand::class, Command\Shortcode\GetVisitsCommand::class, + Command\Shortcode\GeneratePreviewCommand::class, Command\Visit\ProcessVisitsCommand::class, Command\Config\GenerateCharsetCommand::class, Command\Config\GenerateSecretCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index ebc607c8..00e56607 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -14,6 +14,7 @@ return [ Command\Shortcode\ResolveUrlCommand::class => AnnotatedFactory::class, Command\Shortcode\ListShortcodesCommand::class => AnnotatedFactory::class, Command\Shortcode\GetVisitsCommand::class => AnnotatedFactory::class, + Command\Shortcode\GeneratePreviewCommand::class => AnnotatedFactory::class, Command\Visit\ProcessVisitsCommand::class => AnnotatedFactory::class, Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class, Command\Config\GenerateSecretCommand::class => AnnotatedFactory::class, diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index 1f88545f..f0702c6d 100644 Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po index 968701ea..d5e49206 100644 --- a/module/CLI/lang/es.po +++ b/module/CLI/lang/es.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-08-07 20:16+0200\n" -"PO-Revision-Date: 2016-08-07 20:18+0200\n" +"POT-Creation-Date: 2016-08-18 17:24+0200\n" +"PO-Revision-Date: 2016-08-18 17:26+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -68,7 +68,35 @@ msgstr "" msgid "Character set:" msgstr "Grupo de caracteres:" -#, fuzzy +msgid "" +"Generates a random secret string that can be used for JWT token encryption" +msgstr "" +"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar " +"tokens JWT" + +msgid "Secret key:" +msgstr "Clave secreta:" + +msgid "" +"Processes and generates the previews for every URL, improving performance " +"for later web requests." +msgstr "" +"Procesa y genera las vistas previas para cada URL, mejorando el rendimiento " +"para peticiones web posteriores." + +msgid "Finished processing all URLs" +msgstr "Finalizado el procesado de todas las URLs" + +#, php-format +msgid "Processing URL %s..." +msgstr "Procesando URL %s..." + +msgid " Success!" +msgstr "¡Correcto!" + +msgid "Error" +msgstr "Error" + msgid "Generates a short code for provided URL and returns the short URL" msgstr "" "Genera un código corto para la URL proporcionada y devuelve la URL acortada" @@ -149,22 +177,6 @@ msgstr "Has alcanzado la última página" msgid "Continue with page" msgstr "Continuar con la página" -msgid "Processes visits where location is not set yet" -msgstr "Procesa las visitas donde la localización no ha sido establecida aún" - -msgid "Processing IP" -msgstr "Procesando IP" - -msgid "Ignored localhost address" -msgstr "Ignorada IP de localhost" - -#, php-format -msgid "Address located at \"%s\"" -msgstr "Dirección localizada en \"%s\"" - -msgid "Finished processing all IPs" -msgstr "Finalizado el procesado de todas las IPs" - msgid "Returns the long URL behind a short code" msgstr "Devuelve la URL larga detrás de un código corto" @@ -185,3 +197,19 @@ msgstr "URL larga:" #, php-format msgid "Provided short code \"%s\" has an invalid format." msgstr "El código corto proporcionado \"%s\" tiene un formato inválido." + +msgid "Processes visits where location is not set yet" +msgstr "Procesa las visitas donde la localización no ha sido establecida aún" + +msgid "Processing IP" +msgstr "Procesando IP" + +msgid "Ignored localhost address" +msgstr "Ignorada IP de localhost" + +#, php-format +msgid "Address located at \"%s\"" +msgstr "Dirección localizada en \"%s\"" + +msgid "Finished processing all IPs" +msgstr "Finalizado el procesado de todas las IPs" diff --git a/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php new file mode 100644 index 00000000..b224ad61 --- /dev/null +++ b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php @@ -0,0 +1,89 @@ +shortUrlService = $shortUrlService; + $this->previewGenerator = $previewGenerator; + $this->translator = $translator; + parent::__construct(null); + } + + public function configure() + { + $this->setName('shortcode:process-previews') + ->setDescription( + $this->translator->translate( + 'Processes and generates the previews for every URL, improving performance for later web requests.' + ) + ); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $page = 1; + do { + $shortUrls = $this->shortUrlService->listShortUrls($page); + $page += 1; + + foreach ($shortUrls as $shortUrl) { + $this->processUrl($shortUrl->getOriginalUrl(), $output); + } + } while ($page <= $shortUrls->count()); + + $output->writeln('' . $this->translator->translate('Finished processing all URLs') . ''); + } + + protected function processUrl($url, OutputInterface $output) + { + try { + $output->write(sprintf($this->translator->translate('Processing URL %s...'), $url)); + $this->previewGenerator->generatePreview($url); + $output->writeln($this->translator->translate(' Success!')); + } catch (PreviewGenerationException $e) { + $messages = [' ' . $this->translator->translate('Error') . '']; + if ($output->isVerbose()) { + $messages[] = '' . $e->__toString() . ''; + } + + $output->writeln($messages); + } + } +} diff --git a/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php b/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php new file mode 100644 index 00000000..216c004a --- /dev/null +++ b/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php @@ -0,0 +1,99 @@ +previewGenerator = $this->prophesize(PreviewGenerator::class); + $this->shortUrlService = $this->prophesize(ShortUrlService::class); + + $command = new GeneratePreviewCommand( + $this->shortUrlService->reveal(), + $this->previewGenerator->reveal(), + Translator::factory([]) + ); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** + * @test + */ + public function previewsForEveryUrlAreGenerated() + { + $paginator = $this->createPaginator([ + (new ShortUrl())->setOriginalUrl('http://foo.com'), + (new ShortUrl())->setOriginalUrl('https://bar.com'), + (new ShortUrl())->setOriginalUrl('http://baz.com/something'), + ]); + $this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1); + + $this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledTimes(1); + $this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledTimes(1); + $this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:process-previews' + ]); + } + + /** + * @test + */ + public function exceptionWillOutputError() + { + $items = [ + (new ShortUrl())->setOriginalUrl('http://foo.com'), + (new ShortUrl())->setOriginalUrl('https://bar.com'), + (new ShortUrl())->setOriginalUrl('http://baz.com/something'), + ]; + $paginator = $this->createPaginator($items); + $this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1); + $this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class) + ->shouldBeCalledTimes(count($items)); + + $this->commandTester->execute([ + 'command' => 'shortcode:process-previews' + ]); + $output = $this->commandTester->getDisplay(); + $this->assertEquals(count($items), substr_count($output, 'Error')); + } + + protected function createPaginator(array $items) + { + $paginator = new Paginator(new ArrayAdapter($items)); + $paginator->setItemCountPerPage(count($items)); + + return $paginator; + } +} diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php index 4c5c6ace..c995b14d 100644 --- a/module/Common/config/dependencies.config.php +++ b/module/Common/config/dependencies.config.php @@ -4,30 +4,35 @@ use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManager; use Monolog\Logger; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Common\Factory\CacheFactory; -use Shlinkio\Shlink\Common\Factory\EntityManagerFactory; -use Shlinkio\Shlink\Common\Factory\LoggerFactory; -use Shlinkio\Shlink\Common\Factory\TranslatorFactory; +use Shlinkio\Shlink\Common\Factory; +use Shlinkio\Shlink\Common\Image; use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware; -use Shlinkio\Shlink\Common\Service\IpLocationResolver; +use Shlinkio\Shlink\Common\Service; use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension; +use Symfony\Component\Filesystem\Filesystem; use Zend\I18n\Translator\Translator; use Zend\ServiceManager\Factory\InvokableFactory; return [ 'dependencies' => [ + 'invokables' => [ + Filesystem::class => Filesystem::class, + ], 'factories' => [ - EntityManager::class => EntityManagerFactory::class, + EntityManager::class => Factory\EntityManagerFactory::class, GuzzleHttp\Client::class => InvokableFactory::class, - Cache::class => CacheFactory::class, - 'Logger_Shlink' => LoggerFactory::class, + Cache::class => Factory\CacheFactory::class, + 'Logger_Shlink' => Factory\LoggerFactory::class, - Translator::class => TranslatorFactory::class, + Translator::class => Factory\TranslatorFactory::class, TranslatorExtension::class => AnnotatedFactory::class, LocaleMiddleware::class => AnnotatedFactory::class, - IpLocationResolver::class => AnnotatedFactory::class, + Image\ImageBuilder::class => Image\ImageBuilderFactory::class, + + Service\IpLocationResolver::class => AnnotatedFactory::class, + Service\PreviewGenerator::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, diff --git a/module/Common/src/Exception/PreviewGenerationException.php b/module/Common/src/Exception/PreviewGenerationException.php new file mode 100644 index 00000000..ac205495 --- /dev/null +++ b/module/Common/src/Exception/PreviewGenerationException.php @@ -0,0 +1,10 @@ + [ + Image::class => ImageFactory::class, + ]]); + } +} diff --git a/module/Common/src/Image/ImageBuilderInterface.php b/module/Common/src/Image/ImageBuilderInterface.php new file mode 100644 index 00000000..a1479a81 --- /dev/null +++ b/module/Common/src/Image/ImageBuilderInterface.php @@ -0,0 +1,8 @@ +get('config')['phpwkhtmltopdf']; + $image = new Image(isset($config['images']) ? $config['images'] : null); + + if (isset($options) && isset($options['url'])) { + $image->setPage($options['url']); + } + + return $image; + } +} diff --git a/module/Common/src/Service/PreviewGenerator.php b/module/Common/src/Service/PreviewGenerator.php new file mode 100644 index 00000000..03fa392f --- /dev/null +++ b/module/Common/src/Service/PreviewGenerator.php @@ -0,0 +1,70 @@ +location = $location; + $this->imageBuilder = $imageBuilder; + $this->filesystem = $filesystem; + } + + /** + * Generates and stores preview for provided website and returns the path to the image file + * + * @param string $url + * @return string + * @throws PreviewGenerationException + */ + public function generatePreview($url) + { + /** @var Image $image */ + $image = $this->imageBuilder->build(Image::class, ['url' => $url]); + + // If the file already exists, return its path + $cacheId = sprintf('preview_%s.%s', urlencode($url), $image->type); + $path = $this->location . '/' . $cacheId; + if ($this->filesystem->exists($path)) { + return $path; + } + + // Save and check if an error occurred + $image->saveAs($path); + $error = $image->getError(); + if (! empty($error)) { + throw PreviewGenerationException::fromImageError($error); + } + + // Cache the path and return it + return $path; + } +} diff --git a/module/Common/src/Service/PreviewGeneratorInterface.php b/module/Common/src/Service/PreviewGeneratorInterface.php new file mode 100644 index 00000000..2e7ea0aa --- /dev/null +++ b/module/Common/src/Service/PreviewGeneratorInterface.php @@ -0,0 +1,16 @@ +generateBinaryResponse($filePath, [ + 'Content-Disposition' => 'attachment; filename=' . basename($filePath), + 'Content-Transfer-Encoding' => 'Binary', + 'Content-Description' => 'File Transfer', + 'Pragma' => 'public', + 'Expires' => '0', + 'Cache-Control' => 'must-revalidate', + ]); + } + + protected function generateImageResponse($imagePath) + { + return $this->generateBinaryResponse($imagePath); + } + + protected function generateBinaryResponse($path, $extraHeaders = []) + { + $body = new Stream($path); + return new Response($body, 200, ArrayUtils::merge([ + 'Content-Type' => (new \finfo(FILEINFO_MIME))->file($path), + 'Content-Length' => (string) $body->getSize(), + ], $extraHeaders)); + } +} diff --git a/module/Common/test/Image/ImageBuilderFactoryTest.php b/module/Common/test/Image/ImageBuilderFactoryTest.php new file mode 100644 index 00000000..8e07b4a3 --- /dev/null +++ b/module/Common/test/Image/ImageBuilderFactoryTest.php @@ -0,0 +1,29 @@ +factory = new ImageBuilderFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager(), ''); + $this->assertInstanceOf(ImageBuilder::class, $instance); + } +} diff --git a/module/Common/test/Image/ImageFactoryTest.php b/module/Common/test/Image/ImageFactoryTest.php new file mode 100644 index 00000000..c4a3bd6a --- /dev/null +++ b/module/Common/test/Image/ImageFactoryTest.php @@ -0,0 +1,56 @@ +factory = new ImageFactory(); + } + + /** + * @test + */ + public function noPageIsSetWhenOptionsAreNotProvided() + { + /** @var Image $image */ + $image = $this->factory->__invoke(new ServiceManager(['services' => [ + 'config' => ['phpwkhtmltopdf' => []], + ]]), ''); + $this->assertInstanceOf(Image::class, $image); + + $ref = new \ReflectionObject($image); + $page = $ref->getProperty('_page'); + $page->setAccessible(true); + $this->assertNull($page->getValue($image)); + } + + /** + * @test + */ + public function aPageIsSetWhenOptionsIncludeTheUrl() + { + $expectedPage = 'foo/bar.html'; + + /** @var Image $image */ + $image = $this->factory->__invoke(new ServiceManager(['services' => [ + 'config' => ['phpwkhtmltopdf' => []], + ]]), '', ['url' => $expectedPage]); + $this->assertInstanceOf(Image::class, $image); + + $ref = new \ReflectionObject($image); + $page = $ref->getProperty('_page'); + $page->setAccessible(true); + $this->assertEquals($expectedPage, $page->getValue($image)); + } +} diff --git a/module/Common/test/Service/PreviewGeneratorTest.php b/module/Common/test/Service/PreviewGeneratorTest.php new file mode 100644 index 00000000..60b5d024 --- /dev/null +++ b/module/Common/test/Service/PreviewGeneratorTest.php @@ -0,0 +1,89 @@ +image = $this->prophesize(Image::class); + $this->filesystem = $this->prophesize(Filesystem::class); + + $this->generator = new PreviewGenerator(new ImageBuilder(new ServiceManager(), [ + 'factories' => [ + Image::class => function () { + return $this->image->reveal(); + }, + ] + ]), $this->filesystem->reveal(), 'dir'); + } + + /** + * @test + */ + public function alreadyProcessedElementsAreNotProcessed() + { + $url = 'http://foo.com'; + $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(true) + ->shouldBeCalledTimes(1); + $this->image->saveAs(Argument::cetera())->shouldBeCalledTimes(0); + $this->assertEquals(sprintf('dir/preview_%s.png', urlencode($url)), $this->generator->generatePreview($url)); + } + + /** + * @test + */ + public function nonProcessedElementsAreProcessed() + { + $url = 'http://foo.com'; + $cacheId = sprintf('preview_%s.png', urlencode($url)); + $expectedPath = 'dir/' . $cacheId; + + $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false) + ->shouldBeCalledTimes(1); + + $this->image->saveAs($expectedPath)->shouldBeCalledTimes(1); + $this->image->getError()->willReturn('')->shouldBeCalledTimes(1); + $this->assertEquals($expectedPath, $this->generator->generatePreview($url)); + } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Common\Exception\PreviewGenerationException + */ + public function errorWhileGeneratingPreviewThrowsException() + { + $url = 'http://foo.com'; + $cacheId = sprintf('preview_%s.png', urlencode($url)); + $expectedPath = 'dir/' . $cacheId; + + $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false) + ->shouldBeCalledTimes(1); + + $this->image->saveAs($expectedPath)->shouldBeCalledTimes(1); + $this->image->getError()->willReturn('Error!!')->shouldBeCalledTimes(1); + + $this->generator->generatePreview($url); + } +} diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 9f2c9cc9..f5c75bac 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -20,6 +20,7 @@ return [ // Middleware Action\RedirectAction::class => AnnotatedFactory::class, Action\QrCodeAction::class => AnnotatedFactory::class, + Action\PreviewAction::class => AnnotatedFactory::class, Middleware\QrCodeCacheMiddleware::class => AnnotatedFactory::class, ], ], diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php index 9b0c071f..a3c70aeb 100644 --- a/module/Core/config/routes.config.php +++ b/module/Core/config/routes.config.php @@ -13,6 +13,23 @@ return [ ], [ 'name' => 'short-url-qr-code', + 'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]', + 'middleware' => [ + Middleware\QrCodeCacheMiddleware::class, + Action\QrCodeAction::class, + ], + 'allowed_methods' => ['GET'], + ], + [ + 'name' => 'short-url-preview', + 'path' => '/{shortCode}/preview', + 'middleware' => Action\PreviewAction::class, + 'allowed_methods' => ['GET'], + ], + + // Old QR code route. Deprecated + [ + 'name' => 'short-url-qr-code-old', 'path' => '/qr/{shortCode}[/{size:[0-9]+}]', 'middleware' => [ Middleware\QrCodeCacheMiddleware::class, diff --git a/module/Core/src/Action/PreviewAction.php b/module/Core/src/Action/PreviewAction.php new file mode 100644 index 00000000..4c21501c --- /dev/null +++ b/module/Core/src/Action/PreviewAction.php @@ -0,0 +1,85 @@ +previewGenerator = $previewGenerator; + $this->urlShortener = $urlShortener; + } + + /** + * Process an incoming request and/or response. + * + * Accepts a server-side request and a response instance, and does + * something with them. + * + * If the response is not complete and/or further processing would not + * interfere with the work done in the middleware, or if the middleware + * wants to delegate to another process, it can use the `$out` callable + * if present. + * + * If the middleware does not return a value, execution of the current + * request is considered complete, and the response instance provided will + * be considered the response to return. + * + * Alternately, the middleware may return a response instance. + * + * Often, middleware will `return $out();`, with the assumption that a + * later middleware will return a response. + * + * @param Request $request + * @param Response $response + * @param null|callable $out + * @return null|Response + */ + public function __invoke(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + + try { + $url = $this->urlShortener->shortCodeToUrl($shortCode); + if (! isset($url)) { + return $out($request, $response->withStatus(404), 'Not found'); + } + + $imagePath = $this->previewGenerator->generatePreview($url); + return $this->generateImageResponse($imagePath); + } catch (InvalidShortCodeException $e) { + return $out($request, $response->withStatus(404), 'Not found'); + } catch (PreviewGenerationException $e) { + return $out($request, $response->withStatus(500), 'Preview generation error'); + } + } +} diff --git a/module/Core/test/Action/PreviewActionTest.php b/module/Core/test/Action/PreviewActionTest.php new file mode 100644 index 00000000..1ef49307 --- /dev/null +++ b/module/Core/test/Action/PreviewActionTest.php @@ -0,0 +1,114 @@ +previewGenerator = $this->prophesize(PreviewGenerator::class); + $this->urlShortener = $this->prophesize(UrlShortener::class); + $this->action = new PreviewAction($this->previewGenerator->reveal(), $this->urlShortener->reveal()); + } + + /** + * @test + */ + public function invalidShortCodeFallbacksToNextMiddlewareWithStatusNotFound() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + + $this->assertEquals(404, $resp->getStatusCode()); + } + + /** + * @test + */ + public function correctShortCodeReturnsImageResponse() + { + $shortCode = 'abc123'; + $url = 'foobar.com'; + $path = __FILE__; + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($url)->shouldBeCalledTimes(1); + $this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response() + ); + + $this->assertEquals(filesize($path), $resp->getHeaderLine('Content-length')); + $this->assertEquals((new \finfo(FILEINFO_MIME))->file($path), $resp->getHeaderLine('Content-type')); + } + + /** + * @test + */ + public function invalidShortcodeExceptionReturnsNotFound() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) + ->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + + $this->assertEquals(404, $resp->getStatusCode()); + } + + /** + * @test + */ + public function previewExceptionReturnsNotFound() + { + $shortCode = 'abc123'; + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(PreviewGenerationException::class) + ->shouldBeCalledTimes(1); + + $resp = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), + new Response(), + function ($req, $resp) { + return $resp; + } + ); + + $this->assertEquals(500, $resp->getStatusCode()); + } +} diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index 915466d1..a8a16fdf 100644 Binary files a/module/Rest/lang/es.mo and b/module/Rest/lang/es.mo differ diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po index c911722c..9da7489f 100644 --- a/module/Rest/lang/es.po +++ b/module/Rest/lang/es.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-08-07 20:19+0200\n" -"PO-Revision-Date: 2016-08-07 20:21+0200\n" +"POT-Creation-Date: 2016-08-18 17:27+0200\n" +"PO-Revision-Date: 2016-08-18 17:27+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -29,21 +29,19 @@ msgid "A URL was not provided" msgstr "No se ha proporcionado una URL" #, php-format -msgid "Provided URL \"%s\" is invalid. Try with a different one." -msgstr "" -"La URL \"%s\" proporcionada es inválida. Inténtalo de nuevo con una " -"diferente." +msgid "Provided URL %s is invalid. Try with a different one." +msgstr "La URL proporcionada \"%s\" es inválida. Prueba con una diferente." msgid "Unexpected error occurred" msgstr "Ocurrió un error inesperado" #, php-format -msgid "Provided short code \"%s\" is invalid" -msgstr "El código corto \"%s\" proporcionado es inválido" +msgid "Provided short code %s does not exist" +msgstr "El código corto \"%s\" proporcionado no existe" #, php-format -msgid "No URL found for shortcode \"%s\"" -msgstr "No se ha encontrado una URL para el código corto \"%s\"" +msgid "No URL found for short code \"%s\"" +msgstr "No se ha encontrado ninguna URL para el código corto \"%s\"" #, php-format msgid "Provided short code \"%s\" has an invalid format" diff --git a/phpcs.xml b/phpcs.xml index ae134872..469dd989 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,6 +16,7 @@ + bin module config public/index.php