diff --git a/.gitignore b/.gitignore index 2a7fb730..e3bcd671 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ vendor/ .env data/database.sqlite docs/swagger-ui +docker-compose.override.yml diff --git a/.travis-php.ini b/.travis-php.ini deleted file mode 100644 index c9a2ff0c..00000000 --- a/.travis-php.ini +++ /dev/null @@ -1 +0,0 @@ -extension="memcached.so" \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 83d37309..4999fdb5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,11 +6,13 @@ branches: - develop php: - - 5.6 - 7 - 7.1 + - 7.2 -before_install: phpenv config-add .travis-php.ini +before_install: + - phpenv config-add data/infra/travis-php/memcached.ini + - phpenv config-add data/infra/travis-php/apcu.ini before_script: - composer self-update @@ -21,6 +23,7 @@ script: - composer check after_script: + - vendor/bin/phpcov merge build --clover build/clover.xml - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa00a38..a698b793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ ## CHANGELOG +### 1.6.0 + +**Features** + +* [44: Consider allowing to set custom slugs instead of generating a short code](https://github.com/shlinkio/shlink/issues/44) +* [47: Allow to limit short codes availability by date range](https://github.com/shlinkio/shlink/issues/47) +* [48: Allow to limit the number of visits to a short code](https://github.com/shlinkio/shlink/issues/48) +* [105: Added option to enable/disable URL validation by response status code.](https://github.com/shlinkio/shlink/pull/105) + +**Enhancements:** + +* [27: Add repository functional tests with dbunit](https://github.com/shlinkio/shlink/issues/27) +* [86: Drop support for PHP 5](https://github.com/shlinkio/shlink/issues/86) +* [101: Make actions just capture very specific exceptions, and let the ErrorHandler catch any other exception](https://github.com/shlinkio/shlink/issues/101) +* [104: Use different templates for requested-short-code-does-not-exist and route-could-not-be-match](https://github.com/shlinkio/shlink/issues/104) + +**Tasks** + +* [99: Replace AnnotatedFactory by ConfigAbstractFactory](https://github.com/shlinkio/shlink/issues/99) +* [100: Replace twig by plates](https://github.com/shlinkio/shlink/issues/100) +* [102: Improve coding standards strictness](https://github.com/shlinkio/shlink/issues/102) + +**Bugs** + +* [103: Make NotFoundDelegate return proper content types based on accepted content](https://github.com/shlinkio/shlink/issues/103) + ### 1.5.0 **Enhancements:** diff --git a/bin/install b/bin/install index 43a07cd3..e8ecb3c9 100755 --- a/bin/install +++ b/bin/install @@ -1,9 +1,11 @@ #!/usr/bin/env php [ - Application::class => InstallApplicationFactory::class, - Filesystem::class => InvokableFactory::class, - QuestionHelper::class => InvokableFactory::class, -]]); +$container = new ServiceManager([ + 'factories' => [ + Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, + ], + 'services' => [ + 'config' => [ + ConfigAbstractFactory::class => [ + DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] + ], + ], + ], +]); $container->build(Application::class)->run(); diff --git a/bin/update b/bin/update index 164e20b0..d4203528 100755 --- a/bin/update +++ b/bin/update @@ -1,9 +1,11 @@ #!/usr/bin/env php [ - Application::class => InstallApplicationFactory::class, - Filesystem::class => InvokableFactory::class, - QuestionHelper::class => InvokableFactory::class, -]]); +$container = new ServiceManager([ + 'factories' => [ + Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, + ], + 'services' => [ + 'config' => [ + ConfigAbstractFactory::class => [ + DatabaseConfigCustomizerPlugin::class => [QuestionHelper::class, Filesystem::class] + ], + ], + ], +]); $container->build(Application::class, ['isUpdate' => true])->run(); diff --git a/build.sh b/build.sh index b3c65a53..b90d0b2e 100755 --- a/build.sh +++ b/build.sh @@ -20,8 +20,8 @@ cp -R "${projectdir}"/* "${builtcontent}" cd "${builtcontent}" # Install dependencies -rm -r vendor -rm composer.lock +rm -rf vendor +rm -f composer.lock composer self-update composer install --no-dev --optimize-autoloader --no-progress --no-interaction @@ -35,8 +35,8 @@ rm indocker rm docker-compose.yml rm php* rm README.md -rm -r build -rm -f data/database.sqlite +rm -rf build +rm -ff data/database.sqlite rm -rf data/infra rm -rf data/{cache,log,proxies}/{*,.gitignore} rm -rf config/params/{*,.gitignore} diff --git a/composer.json b/composer.json index 6299cd08..8cbe3267 100644 --- a/composer.json +++ b/composer.json @@ -12,36 +12,45 @@ } ], "require": { - "php": "^5.6 || ^7.0", + "php": "^7.0", + "acelaya/ze-content-based-error-handler": "^2.0", + "cocur/slugify": "^3.0", + "doctrine/annotations": "^1.4 <1.5", + "doctrine/cache": "^1.6 <1.7", + "doctrine/collections": "^1.4 <1.5", + "doctrine/common": "^2.7 <2.8", + "doctrine/dbal": "^2.5 <2.6", + "doctrine/migrations": "^1.4", + "doctrine/orm": "^2.5 <2.6", + "endroid/qrcode": "^1.7", + "firebase/php-jwt": "^4.0", + "guzzlehttp/guzzle": "^6.2", + "http-interop/http-middleware": "^0.4.1", + "mikehaertl/phpwkhtmltopdf": "^2.2", + "monolog/monolog": "^1.21", + "roave/security-advisories": "dev-master", + "symfony/console": "^3.0", + "symfony/filesystem": "^3.0", + "symfony/process": "^3.0", + "theorchard/monolog-cascade": "^0.4", + "zendframework/zend-config": "^3.0", + "zendframework/zend-config-aggregator": "^1.0", "zendframework/zend-expressive": "^2.0", "zendframework/zend-expressive-fastroute": "^2.0", - "zendframework/zend-expressive-twigrenderer": "^1.4", - "zendframework/zend-stdlib": "^3.0", - "zendframework/zend-servicemanager": "^3.0", - "zendframework/zend-paginator": "^2.6", - "zendframework/zend-config": "^3.0", + "zendframework/zend-expressive-helpers": "^4.2", + "zendframework/zend-expressive-platesrenderer": "^1.3", "zendframework/zend-i18n": "^2.7", - "zendframework/zend-config-aggregator": "^0.1", - "acelaya/zsm-annotated-services": "^1.0", - "acelaya/ze-content-based-error-handler": "^2.0", - "doctrine/orm": "^2.5", - "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", - "mikehaertl/phpwkhtmltopdf": "^2.2", - "doctrine/migrations": "^1.4", - "http-interop/http-middleware": "^0.4" + "zendframework/zend-paginator": "^2.6", + "zendframework/zend-servicemanager": "^3.0", + "zendframework/zend-stdlib": "^3.0" }, "require-dev": { - "phpunit/phpunit": "^5.7 || ^6.0", - "squizlabs/php_codesniffer": "^2.3", - "roave/security-advisories": "dev-master", "filp/whoops": "^2.0", + "phpunit/dbunit": "^3.0", + "phpunit/phpcov": "^4.0", + "phpunit/phpunit": "^6.0", + "slevomat/coding-standard": "^4.0", + "squizlabs/php_codesniffer": "^3.1", "symfony/var-dumper": "^3.0", "vlucas/phpdotenv": "^2.2", "zendframework/zend-expressive-tooling": "^0.4" @@ -61,22 +70,39 @@ "psr-4": { "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", - "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", - "ShlinkioTest\\Shlink\\Common\\": "module/Common/test" + "ShlinkioTest\\Shlink\\Core\\": [ + "module/Core/test", + "module/Core/test-func" + ], + "ShlinkioTest\\Shlink\\Common\\": [ + "module/Common/test", + "module/Common/test-func" + ] } }, "scripts": { "check": [ "@cs", - "@test" + "@test", + "@func-test" ], "cs": "phpcs", "cs-fix": "phpcbf", "serve": "php -S 0.0.0.0:8000 -t public/", - "test": "phpunit --coverage-clover build/clover.xml", - "pretty-test": "phpunit --coverage-html build/coverage" + "test": "phpunit --coverage-php build/coverage-unit.cov", + "pretty-test": "phpunit --coverage-html build/coverage", + "func-test": "phpunit -c phpunit-func.xml --coverage-php build/coverage-func.cov", + "complete-pretty-test": [ + "@test", + "@func-test", + "phpcov merge build --html build/html" + ] }, "config": { - "process-timeout": 0 + "process-timeout": 0, + "sort-packages": true, + "platform": { + "php": "7.0" + } } } diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index fd7147a1..0f8163bf 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -1,4 +1,6 @@ [ 'factories' => [ Expressive\Application::class => Container\ApplicationFactory::class, - Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class, - \Twig_Environment::class => Twig\TwigEnvironmentFactory::class, + Template\TemplateRendererInterface::class => Plates\PlatesRendererFactory::class, Router\RouterInterface::class => Router\FastRouteRouterFactory::class, ErrorHandler::class => Container\ErrorHandlerFactory::class, Middleware\ImplicitOptionsMiddleware::class => EmptyResponseImplicitOptionsMiddlewareFactory::class, + + Helper\UrlHelper::class => Helper\UrlHelperFactory::class, + Helper\ServerUrlHelper::class => InvokableFactory::class, ], ], diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 803eb8ff..89961f77 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -1,4 +1,6 @@ [ diff --git a/config/autoload/preview-generation.global.php b/config/autoload/preview-generation.global.php index b4f14da3..4a4fa9d1 100644 --- a/config/autoload/preview-generation.global.php +++ b/config/autoload/preview-generation.global.php @@ -1,4 +1,6 @@ [ diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index fb026248..deb875f3 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -1,4 +1,6 @@ [ - 'cache_dir' => 'data/cache/twig', + 'templates' => [ + 'extension' => 'phtml', + ], + + 'plates' => [ 'extensions' => [ // extension service names or instances ], diff --git a/config/autoload/translator.global.php b/config/autoload/translator.global.php index 2ce6bb44..2b03058c 100644 --- a/config/autoload/translator.global.php +++ b/config/autoload/translator.global.php @@ -1,4 +1,6 @@ [ 'domain' => [ - 'schema' => Common\env('SHORTENED_URL_SCHEMA', 'http'), - 'hostname' => Common\env('SHORTENED_URL_HOSTNAME'), + 'schema' => env('SHORTENED_URL_SCHEMA', 'http'), + 'hostname' => env('SHORTENED_URL_HOSTNAME'), ], - 'shortcode_chars' => Common\env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS), + 'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS), 'validate_url' => true, ], diff --git a/config/autoload/zend-expressive.global.php b/config/autoload/zend-expressive.global.php index aa2e9d3b..9a6e582e 100644 --- a/config/autoload/zend-expressive.global.php +++ b/config/autoload/zend-expressive.global.php @@ -1,4 +1,5 @@ $arg) { + if ($arg === '--test') { + unset($_SERVER['argv'][$i]); + $isTest = true; + break; + } +} + +/** @var ContainerInterface|ServiceManager $container */ $container = include __DIR__ . '/container.php'; + +// If in testing env, override DB connection to use an in-memory sqlite database +if ($isTest) { + $container->setAllowOverride(true); + $config = $container->get('config'); + $config['entity_manager']['connection'] = [ + 'driver' => 'pdo_sqlite', + 'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db', + ]; + $container->setService('config', $config); +} + /** @var EntityManager $em */ $em = $container->get(EntityManager::class); diff --git a/config/config.php b/config/config.php index 32644060..470aec64 100644 --- a/config/config.php +++ b/config/config.php @@ -1,4 +1,6 @@ load(); } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 28d9d6f3..fac0b498 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -26,7 +26,7 @@ RUN apk add --no-cache --virtual libpng-dev RUN docker-php-ext-install gd # Install redis extension -ADD https://github.com/phpredis/phpredis/archive/php7.tar.gz /tmp/phpredis.tar.gz +ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz RUN mkdir -p /usr/src/php/ext/redis\ && tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1 # configure and install @@ -85,3 +85,6 @@ RUN rm /tmp/xdebug.tar.gz RUN php -r "readfile('https://getcomposer.org/installer');" | php RUN chmod +x composer.phar RUN mv composer.phar /usr/local/bin/composer + +# Make home directory writable by anyone +RUN chmod 777 /home diff --git a/data/infra/travis-php/apcu.ini b/data/infra/travis-php/apcu.ini new file mode 100644 index 00000000..4a2dd79a --- /dev/null +++ b/data/infra/travis-php/apcu.ini @@ -0,0 +1 @@ +extension="apcu.so" diff --git a/data/infra/travis-php/memcached.ini b/data/infra/travis-php/memcached.ini new file mode 100644 index 00000000..6b9b24f3 --- /dev/null +++ b/data/infra/travis-php/memcached.ini @@ -0,0 +1 @@ +extension="memcached.so" diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php index 40200c53..cd6a3f5a 100644 --- a/data/migrations/Version20160819142757.php +++ b/data/migrations/Version20160819142757.php @@ -1,4 +1,5 @@ getTable('short_urls'); + if ($shortUrls->hasColumn('value_since')) { + return; + } + + $shortUrls->addColumn('valid_since', Type::DATETIME, [ + 'notnull' => false, + ]); + $shortUrls->addColumn('valid_until', Type::DATETIME, [ + 'notnull' => false, + ]); + } + + /** + * @param Schema $schema + * @throws SchemaException + */ + public function down(Schema $schema) + { + $shortUrls = $schema->getTable('short_urls'); + if (! $shortUrls->hasColumn('value_since')) { + return; + } + + $shortUrls->dropColumn('valid_since'); + $shortUrls->dropColumn('valid_until'); + } +} diff --git a/data/migrations/Version20171022064541.php b/data/migrations/Version20171022064541.php new file mode 100644 index 00000000..ef0447aa --- /dev/null +++ b/data/migrations/Version20171022064541.php @@ -0,0 +1,46 @@ +getTable('short_urls'); + if ($shortUrls->hasColumn('max_visits')) { + return; + } + + $shortUrls->addColumn('max_visits', Type::INTEGER, [ + 'unsigned' => true, + 'notnull' => false, + ]); + } + + /** + * @param Schema $schema + * @throws SchemaException + */ + public function down(Schema $schema) + { + $shortUrls = $schema->getTable('short_urls'); + if (! $shortUrls->hasColumn('max_visits')) { + return; + } + + $shortUrls->dropColumn('max_visits'); + } +} diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist new file mode 100644 index 00000000..b347b9e8 --- /dev/null +++ b/docker-compose.override.yml.dist @@ -0,0 +1,8 @@ +version: '2' + +services: + shlink_php: + user: 1000:1000 + volumes: + - /etc/passwd:/etc/passwd:ro + - /etc/group:/etc/group:ro diff --git a/func_tests_bootstrap.php b/func_tests_bootstrap.php new file mode 100644 index 00000000..6a4300f3 --- /dev/null +++ b/func_tests_bootstrap.php @@ -0,0 +1,33 @@ +setAllowOverride(true); +$config = $sm->get('config'); +$config['entity_manager']['connection'] = [ + 'driver' => 'pdo_sqlite', + 'path' => $shlinkDbPath, +]; +$sm->setService('config', $config); + +// Create database +$process = new Process('vendor/bin/doctrine orm:schema-tool:create --no-interaction -q --test', __DIR__); +$process->inheritEnvironmentVariables() + ->mustRun(); + +DatabaseTestCase::$em = $sm->get('em'); diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index a1a34c16..be1bda6b 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -1,4 +1,6 @@ [ Application::class => ApplicationFactory::class, - Command\Shortcode\GenerateShortcodeCommand::class => AnnotatedFactory::class, - 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, - Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class, - Command\Api\DisableKeyCommand::class => AnnotatedFactory::class, - Command\Api\ListKeysCommand::class => AnnotatedFactory::class, - Command\Tag\ListTagsCommand::class => AnnotatedFactory::class, - Command\Tag\CreateTagCommand::class => AnnotatedFactory::class, - Command\Tag\RenameTagCommand::class => AnnotatedFactory::class, - Command\Tag\DeleteTagsCommand::class => AnnotatedFactory::class, + Command\Shortcode\GenerateShortcodeCommand::class => ConfigAbstractFactory::class, + Command\Shortcode\ResolveUrlCommand::class => ConfigAbstractFactory::class, + Command\Shortcode\ListShortcodesCommand::class => ConfigAbstractFactory::class, + Command\Shortcode\GetVisitsCommand::class => ConfigAbstractFactory::class, + Command\Shortcode\GeneratePreviewCommand::class => ConfigAbstractFactory::class, + Command\Visit\ProcessVisitsCommand::class => ConfigAbstractFactory::class, + Command\Config\GenerateCharsetCommand::class => ConfigAbstractFactory::class, + Command\Config\GenerateSecretCommand::class => ConfigAbstractFactory::class, + Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, + Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, + Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class, + Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, + Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class, + Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, + Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class, ], ], + ConfigAbstractFactory::class => [ + Command\Shortcode\GenerateShortcodeCommand::class => [ + Service\UrlShortener::class, + 'translator', + 'config.url_shortener.domain', + ], + Command\Shortcode\ResolveUrlCommand::class => [Service\UrlShortener::class, 'translator'], + Command\Shortcode\ListShortcodesCommand::class => [Service\ShortUrlService::class, 'translator'], + Command\Shortcode\GetVisitsCommand::class => [Service\VisitsTracker::class, 'translator'], + Command\Shortcode\GeneratePreviewCommand::class => [ + Service\ShortUrlService::class, + PreviewGenerator::class, + 'translator', + ], + Command\Visit\ProcessVisitsCommand::class => [ + Service\VisitService::class, + IpLocationResolver::class, + 'translator', + ], + Command\Config\GenerateCharsetCommand::class => ['translator'], + Command\Config\GenerateSecretCommand::class => ['translator'], + Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, 'translator'], + Command\Api\DisableKeyCommand::class => [ApiKeyService::class, 'translator'], + Command\Api\ListKeysCommand::class => [ApiKeyService::class, 'translator'], + Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class, Translator::class], + Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class, Translator::class], + Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class, Translator::class], + Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class, Translator::class], + ], + ]; diff --git a/module/CLI/config/translator.config.php b/module/CLI/config/translator.config.php index ae120db3..659d4cae 100644 --- a/module/CLI/config/translator.config.php +++ b/module/CLI/config/translator.config.php @@ -1,4 +1,6 @@ [ diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index 93bf6559..472ff447 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 0a456205..db466f85 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: 2017-07-16 09:35+0200\n" -"PO-Revision-Date: 2017-07-16 09:39+0200\n" +"POT-Creation-Date: 2017-10-21 20:17+0200\n" +"PO-Revision-Date: 2017-10-21 20:19+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -92,7 +92,7 @@ msgid "Processing URL %s..." msgstr "Procesando URL %s..." msgid " Success!" -msgstr "¡Correcto!" +msgstr " ¡Correcto!" msgid "Error" msgstr "Error" @@ -107,6 +107,24 @@ msgstr "La URL larga a procesar" msgid "Tags to apply to the new short URL" msgstr "Etiquetas a aplicar a la nueva URL acortada" +msgid "" +"The date from which this short URL will be valid. If someone tries to access " +"it before this date, it will not be found." +msgstr "" +"La fecha desde la cual será válida esta URL acortada. Si alguien intenta " +"acceder a ella antes de esta fecha, no será encontrada." + +msgid "" +"The date until which this short URL will be valid. If someone tries to " +"access it after this date, it will not be found." +msgstr "" +"La fecha hasta la cual será válida está URL acortada. Si alguien intenta " +"acceder a ella después de esta fecha, no será encontrada." + +msgid "If provided, this slug will be used instead of generating a short code" +msgstr "" +"Si se proporciona, este slug será usado en vez de generar un código corto" + msgid "A long URL was not provided. Which URL do you want to shorten?:" msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" @@ -123,6 +141,14 @@ msgstr "URL generada:" msgid "Provided URL \"%s\" is invalid. Try with a different one." msgstr "La URL proporcionada \"%s\" e inválida. Prueba con una diferente." +#, php-format +msgid "" +"Provided slug \"%s\" is already in use by another URL. Try with a different " +"one." +msgstr "" +"El slug proporcionado \"%s\" ya está siendo usado para otra URL. Prueba con " +"uno diferente." + msgid "Returns the detailed visits information for provided short code" msgstr "" "Devuelve la información detallada de visitas para el código corto " @@ -141,7 +167,7 @@ msgstr "" "Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate" msgid "A short code was not provided. Which short code do you want to use?:" -msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?" +msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?:" msgid "Referer" msgstr "Origen" @@ -210,7 +236,7 @@ msgstr "El código corto a convertir" msgid "A short code was not provided. Which short code do you want to parse?:" msgstr "" -"No se proporcionó un código corto. ¿Qué código corto quieres convertir?" +"No se proporcionó un código corto. ¿Qué código corto quieres convertir?:" #, php-format msgid "No URL found for short code \"%s\"" @@ -223,6 +249,10 @@ msgstr "URL larga:" msgid "Provided short code \"%s\" has an invalid format." msgstr "El código corto proporcionado \"%s\" tiene un formato inválido." +#, php-format +msgid "Provided short code \"%s\" could not be found." +msgstr "El código corto proporcionado \"%s\" no ha podido ser encontrado." + msgid "Creates one or more tags." msgstr "Crea una o más etiquetas." diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 48d9d564..e4390203 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -1,8 +1,8 @@ apiKeyService = $apiKeyService; diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 75d94e65..27006ceb 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -1,8 +1,8 @@ apiKeyService = $apiKeyService; $this->translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index b08c1ece..a32c9160 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -1,9 +1,9 @@ apiKeyService = $apiKeyService; $this->translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() diff --git a/module/CLI/src/Command/Config/GenerateCharsetCommand.php b/module/CLI/src/Command/Config/GenerateCharsetCommand.php index 22369934..189dedf3 100644 --- a/module/CLI/src/Command/Config/GenerateCharsetCommand.php +++ b/module/CLI/src/Command/Config/GenerateCharsetCommand.php @@ -1,7 +1,8 @@ translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() diff --git a/module/CLI/src/Command/Config/GenerateSecretCommand.php b/module/CLI/src/Command/Config/GenerateSecretCommand.php index bef5c86a..6bb1e232 100644 --- a/module/CLI/src/Command/Config/GenerateSecretCommand.php +++ b/module/CLI/src/Command/Config/GenerateSecretCommand.php @@ -1,7 +1,8 @@ translator = $translator; - parent::__construct(null); + parent::__construct(); } public function configure() diff --git a/module/CLI/src/Command/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php index 93de4305..6658678d 100644 --- a/module/CLI/src/Command/Install/InstallCommand.php +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -1,4 +1,6 @@ addOption( 'tags', 't', - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, $this->translator->translate('Tags to apply to the new short URL') - ); + ) + ->addOption('validSince', 's', InputOption::VALUE_REQUIRED, $this->translator->translate( + 'The date from which this short URL will be valid. ' + . 'If someone tries to access it before this date, it will not be found.' + )) + ->addOption('validUntil', 'u', InputOption::VALUE_REQUIRED, $this->translator->translate( + 'The date until which this short URL will be valid. ' + . 'If someone tries to access it after this date, it will not be found.' + )) + ->addOption('customSlug', 'c', InputOption::VALUE_REQUIRED, $this->translator->translate( + 'If provided, this slug will be used instead of generating a short code' + )) + ->addOption('maxVisits', 'm', InputOption::VALUE_REQUIRED, $this->translator->translate( + 'This will limit the number of visits for this short URL.' + )); } public function interact(InputInterface $input, OutputInterface $output) @@ -94,6 +101,8 @@ class GenerateShortcodeCommand extends Command $processedTags = array_merge($processedTags, $explodedTags); } $tags = $processedTags; + $customSlug = $input->getOption('customSlug'); + $maxVisits = $input->getOption('maxVisits'); try { if (! isset($longUrl)) { @@ -101,7 +110,14 @@ class GenerateShortcodeCommand extends Command return; } - $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags); + $shortCode = $this->urlShortener->urlToShortCode( + new Uri($longUrl), + $tags, + $this->getOptionalDate($input, 'validSince'), + $this->getOptionalDate($input, 'validUntil'), + $customSlug, + $maxVisits !== null ? (int) $maxVisits : null + ); $shortUrl = (new Uri())->withPath($shortCode) ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); @@ -117,6 +133,19 @@ class GenerateShortcodeCommand extends Command ) . '', $longUrl )); + } catch (NonUniqueSlugException $e) { + $output->writeln(sprintf( + '' . $this->translator->translate( + 'Provided slug "%s" is already in use by another URL. Try with a different one.' + ) . '', + $customSlug + )); } } + + private function getOptionalDate(InputInterface $input, string $fieldName) + { + $since = $input->getOption($fieldName); + return $since !== null ? new \DateTime($since) : null; + } } diff --git a/module/CLI/src/Command/Shortcode/GetVisitsCommand.php b/module/CLI/src/Command/Shortcode/GetVisitsCommand.php index 5943e4aa..40192dde 100644 --- a/module/CLI/src/Command/Shortcode/GetVisitsCommand.php +++ b/module/CLI/src/Command/Shortcode/GetVisitsCommand.php @@ -1,9 +1,9 @@ visitsTracker = $visitsTracker; diff --git a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php index a8594db7..4d402522 100644 --- a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php +++ b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php @@ -1,10 +1,10 @@ shortUrlService = $shortUrlService; @@ -88,12 +81,11 @@ class ListShortcodesCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { - $page = intval($input->getOption('page')); + $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('searchTerm'); $tags = $input->getOption('tags'); $tags = ! empty($tags) ? explode(',', $tags) : []; $showTags = $input->getOption('showTags'); - $orderBy = $input->getOption('orderBy'); /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); diff --git a/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php b/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php index a94b47ad..fe79ee16 100644 --- a/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php +++ b/module/CLI/src/Command/Shortcode/ResolveUrlCommand.php @@ -1,9 +1,10 @@ urlShortener = $urlShortener; @@ -88,6 +82,10 @@ class ResolveUrlCommand extends Command $output->writeln(sprintf('' . $this->translator->translate( 'Provided short code "%s" has an invalid format.' ) . '', $shortCode)); + } catch (EntityDoesNotExistException $e) { + $output->writeln(sprintf('' . $this->translator->translate( + 'Provided short code "%s" could not be found.' + ) . '', $shortCode)); } } } diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 8a06d38c..183dbe51 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -1,14 +1,13 @@ tagService = $tagService; diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 0a4e271b..de7cf5f4 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -1,14 +1,13 @@ tagService = $tagService; diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index eb120226..44a3f48e 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -1,15 +1,14 @@ tagService = $tagService; diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index e3ee678e..d5473369 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -1,15 +1,14 @@ tagService = $tagService; diff --git a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php index 195d891c..51adc356 100644 --- a/module/CLI/src/Command/Visit/ProcessVisitsCommand.php +++ b/module/CLI/src/Command/Visit/ProcessVisitsCommand.php @@ -1,12 +1,11 @@ get(Filesystem::class), new ConfigCustomizerPluginManager($container, ['factories' => [ - Plugin\DatabaseConfigCustomizerPlugin::class => AnnotatedFactory::class, + Plugin\DatabaseConfigCustomizerPlugin::class => ConfigAbstractFactory::class, Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, diff --git a/module/CLI/src/Install/ConfigCustomizerPluginManager.php b/module/CLI/src/Install/ConfigCustomizerPluginManager.php index c8f0e7cb..45f2da9a 100644 --- a/module/CLI/src/Install/ConfigCustomizerPluginManager.php +++ b/module/CLI/src/Install/ConfigCustomizerPluginManager.php @@ -1,4 +1,6 @@ Do you want to validate long urls by 200 HTTP status code on response (Y/n):' ) - ) + ), ]); } } diff --git a/module/CLI/src/Model/CustomizableAppConfig.php b/module/CLI/src/Model/CustomizableAppConfig.php index 34f3674c..5784f753 100644 --- a/module/CLI/src/Model/CustomizableAppConfig.php +++ b/module/CLI/src/Model/CustomizableAppConfig.php @@ -1,4 +1,6 @@ $urlShortener['domain']['schema'], 'HOSTNAME' => $urlShortener['domain']['hostname'], 'CHARS' => $urlShortener['shortcode_chars'], - 'VALIDATE_URL' => $urlShortener['validate_url'], + 'VALIDATE_URL' => $urlShortener['validate_url'] ?? true, ]); } } @@ -241,7 +243,7 @@ final class CustomizableAppConfig implements ArraySerializableInterface 'hostname' => $this->urlShortener['HOSTNAME'], ], 'shortcode_chars' => $this->urlShortener['CHARS'], - 'validate_url' => $this->urlShortener['VALIDATE_URL'] + 'validate_url' => $this->urlShortener['VALIDATE_URL'], ], ]; diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index c265b3dd..f577f298 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -1,4 +1,6 @@ previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1); $this->commandTester->execute([ - 'command' => 'shortcode:process-previews' + 'command' => 'shortcode:process-previews', ]); } @@ -83,7 +85,7 @@ class GeneratePreviewCommandTest extends TestCase ->shouldBeCalledTimes(count($items)); $this->commandTester->execute([ - 'command' => 'shortcode:process-previews' + 'command' => 'shortcode:process-previews', ]); $output = $this->commandTester->getDisplay(); $this->assertEquals(count($items), substr_count($output, 'Error')); diff --git a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php index 11a1385d..e1dc5229 100644 --- a/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php @@ -1,4 +1,6 @@ urlShortener = $this->prophesize(UrlShortener::class); $command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [ 'schema' => 'http', - 'hostname' => 'foo.com' + 'hostname' => 'foo.com', ]); $app = new Application(); $app->add($command); @@ -44,7 +46,7 @@ class GenerateShortcodeCommandTest extends TestCase $this->commandTester->execute([ 'command' => 'shortcode:generate', - 'longUrl' => 'http://domain.com/foo/bar' + 'longUrl' => 'http://domain.com/foo/bar', ]); $output = $this->commandTester->getDisplay(); $this->assertTrue(strpos($output, 'http://foo.com/abc123') > 0); @@ -60,7 +62,7 @@ class GenerateShortcodeCommandTest extends TestCase $this->commandTester->execute([ 'command' => 'shortcode:generate', - 'longUrl' => 'http://domain.com/invalid' + 'longUrl' => 'http://domain.com/invalid', ]); $output = $this->commandTester->getDisplay(); $this->assertTrue( diff --git a/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php b/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php index 4ec77e71..d80d6d4f 100644 --- a/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php @@ -1,4 +1,6 @@ urlShortener->shortCodeToUrl($shortCode)->willReturn(null) + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) ->shouldBeCalledTimes(1); $this->commandTester->execute([ @@ -63,7 +66,7 @@ class ResolveUrlCommandTest extends TestCase 'shortCode' => $shortCode, ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals('No URL found for short code "' . $shortCode . '"' . PHP_EOL, $output); + $this->assertEquals('Provided short code "' . $shortCode . '" could not be found.' . PHP_EOL, $output); } /** diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index 5042e695..6dab8d86 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -1,4 +1,6 @@ [ - 'invokables' => [ - Filesystem::class => Filesystem::class, - ], 'factories' => [ EntityManager::class => Factory\EntityManagerFactory::class, GuzzleHttp\Client::class => InvokableFactory::class, Cache::class => Factory\CacheFactory::class, 'Logger_Shlink' => Factory\LoggerFactory::class, + Filesystem::class => InvokableFactory::class, Translator::class => Factory\TranslatorFactory::class, - TranslatorExtension::class => AnnotatedFactory::class, - LocaleMiddleware::class => AnnotatedFactory::class, + TranslatorExtension::class => ConfigAbstractFactory::class, + LocaleMiddleware::class => ConfigAbstractFactory::class, Image\ImageBuilder::class => Image\ImageBuilderFactory::class, - Service\IpLocationResolver::class => AnnotatedFactory::class, - Service\PreviewGenerator::class => AnnotatedFactory::class, + Service\IpLocationResolver::class => ConfigAbstractFactory::class, + Service\PreviewGenerator::class => ConfigAbstractFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, 'httpClient' => GuzzleHttp\Client::class, 'translator' => Translator::class, 'logger' => LoggerInterface::class, - AnnotatedFactory::CACHE_SERVICE => Cache::class, Logger::class => 'Logger_Shlink', LoggerInterface::class => 'Logger_Shlink', ], + 'abstract_factories' => [ + Factory\DottedAccessConfigAbstractFactory::class, + ], + ], + + ConfigAbstractFactory::class => [ + TranslatorExtension::class => ['translator'], + LocaleMiddleware::class => ['translator'], + Service\IpLocationResolver::class => ['httpClient'], + Service\PreviewGenerator::class => [ + ImageBuilder::class, + Filesystem::class, + 'config.preview_generation.files_location', + ], ], ]; diff --git a/module/Common/config/templates.config.php b/module/Common/config/templates.config.php index 903c1e8c..eb25ef78 100644 --- a/module/Common/config/templates.config.php +++ b/module/Common/config/templates.config.php @@ -1,9 +1,11 @@ [ + 'plates' => [ 'extensions' => [ TranslatorExtension::class, ], diff --git a/module/Common/functions/functions.php b/module/Common/functions/functions.php index 4956ddf0..ae109612 100644 --- a/module/Common/functions/functions.php +++ b/module/Common/functions/functions.php @@ -1,4 +1,6 @@ 0; + } + + /** + * Create an object + * + * @param ContainerInterface $container + * @param string $requestedName + * @param null|array $options + * @return object + * @throws InvalidArgumentException + * @throws ServiceNotFoundException if unable to resolve the service. + * @throws ServiceNotCreatedException if an exception is raised when + * creating a service. + * @throws ContainerException if any other error occurs + */ + public function __invoke(ContainerInterface $container, $requestedName, array $options = null) + { + $parts = explode('.', $requestedName); + $serviceName = array_shift($parts); + if (! $container->has($serviceName)) { + throw new ServiceNotCreatedException(sprintf( + 'Defined service "%s" could not be found in container after resolving dotted expression "%s".', + $serviceName, + $requestedName + )); + } + + $array = $container->get($serviceName); + return $this->readKeysFromArray($parts, $array); + } + + /** + * @param array $keys + * @param array|\ArrayAccess $array + * @return mixed|null + * @throws InvalidArgumentException + */ + private function readKeysFromArray(array $keys, $array) + { + $key = array_shift($keys); + + // When one of the provided keys is not found, throw an exception + if (! isset($array[$key])) { + throw new InvalidArgumentException(sprintf( + 'The key "%s" provided in the dotted notation could not be found in the array service', + $key + )); + } + + $value = $array[$key]; + if (! empty($keys) && (is_array($value) || $value instanceof \ArrayAccess)) { + $value = $this->readKeysFromArray($keys, $value); + } + + return $value; + } +} diff --git a/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php b/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php index 1551d8cd..2ddf4080 100644 --- a/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php +++ b/module/Common/src/Factory/EmptyResponseImplicitOptionsMiddlewareFactory.php @@ -1,4 +1,6 @@ translator = $translator; diff --git a/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php index 995c7263..b57bb6e8 100644 --- a/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php +++ b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php @@ -1,4 +1,6 @@ paginableRepository = $paginableRepository; - $this->searchTerm = trim(strip_tags($searchTerm)); + $this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null; $this->orderBy = $orderBy; $this->tags = $tags; } diff --git a/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php b/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php index a1e0ec5c..167de806 100644 --- a/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php +++ b/module/Common/src/Paginator/Util/PaginatorUtilsTrait.php @@ -1,4 +1,6 @@ httpClient = $httpClient; @@ -34,7 +29,7 @@ class IpLocationResolver implements IpLocationResolverInterface { try { $response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress)); - return json_decode($response->getBody(), true); + return json_decode((string) $response->getBody(), true); } catch (GuzzleException $e) { throw WrongIpException::fromIpAddress($ipAddress, $e); } diff --git a/module/Common/src/Service/IpLocationResolverInterface.php b/module/Common/src/Service/IpLocationResolverInterface.php index 350c2b9c..4f4279a2 100644 --- a/module/Common/src/Service/IpLocationResolverInterface.php +++ b/module/Common/src/Service/IpLocationResolverInterface.php @@ -1,4 +1,6 @@ location = $location; diff --git a/module/Common/src/Service/PreviewGeneratorInterface.php b/module/Common/src/Service/PreviewGeneratorInterface.php index 2e7ea0aa..f9e5dcae 100644 --- a/module/Common/src/Service/PreviewGeneratorInterface.php +++ b/module/Common/src/Service/PreviewGeneratorInterface.php @@ -1,4 +1,6 @@ translator = $translator; + } + + public function register(Engine $engine) + { + $engine->registerFunction('translate', [$this->translator, 'translate']); + $engine->registerFunction('translate_plural', [$this->translator, 'translatePlural']); + } +} diff --git a/module/Common/src/Twig/Extension/TranslatorExtension.php b/module/Common/src/Twig/Extension/TranslatorExtension.php deleted file mode 100644 index 48ee3f11..00000000 --- a/module/Common/src/Twig/Extension/TranslatorExtension.php +++ /dev/null @@ -1,75 +0,0 @@ -translator = $translator; - } - - /** - * Returns the name of the extension. - * - * @return string The extension name - */ - public function getName() - { - return __CLASS__; - } - - public function getFunctions() - { - return [ - new \Twig_SimpleFunction('translate', [$this, 'translate']), - new \Twig_SimpleFunction('translate_plural', [$this, 'translatePlural']), - ]; - } - - /** - * Translate a message. - * - * @param string $message - * @param string $textDomain - * @param string $locale - * @return string - */ - public function translate($message, $textDomain = 'default', $locale = null) - { - return $this->translator->translate($message, $textDomain, $locale); - } - - /** - * Translate a plural message. - * - * @param string $singular - * @param string $plural - * @param int $number - * @param string $textDomain - * @param string|null $locale - * @return string - */ - public function translatePlural( - $singular, - $plural, - $number, - $textDomain = 'default', - $locale = null - ) { - $this->translator->translatePlural($singular, $plural, $number, $textDomain, $locale); - } -} diff --git a/module/Common/src/Util/DateRange.php b/module/Common/src/Util/DateRange.php index c87f402a..215de520 100644 --- a/module/Common/src/Util/DateRange.php +++ b/module/Common/src/Util/DateRange.php @@ -1,4 +1,6 @@ generateBinaryResponse($filePath, [ 'Content-Disposition' => 'attachment; filename=' . basename($filePath), @@ -19,12 +22,12 @@ trait ResponseUtilsTrait ]); } - protected function generateImageResponse($imagePath) + protected function generateImageResponse(string $imagePath): ResponseInterface { return $this->generateBinaryResponse($imagePath); } - protected function generateBinaryResponse($path, $extraHeaders = []) + protected function generateBinaryResponse(string $path, array $extraHeaders = []): ResponseInterface { $body = new Stream($path); return new Response($body, 200, ArrayUtils::merge([ diff --git a/module/Common/src/Util/StringUtilsTrait.php b/module/Common/src/Util/StringUtilsTrait.php index 9680aa49..1c048251 100644 --- a/module/Common/src/Util/StringUtilsTrait.php +++ b/module/Common/src/Util/StringUtilsTrait.php @@ -1,4 +1,6 @@ getConnection()->getWrappedConnection(); + return self::$conn = $this->createDefaultDBConnection($pdo, static::$em->getConnection()->getDatabase()); + } + + public function getDataSet(): DataSet + { + return $this->createArrayDataSet([]); + } + + protected function getEntityManager(): EntityManagerInterface + { + return static::$em; + } + + public function tearDown() + { + // Empty all entity tables defined by this test after each test + foreach (static::ENTITIES_TO_EMPTY as $entityClass) { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->delete($entityClass, 'x'); + $qb->getQuery()->execute(); + } + + // Clear entity manager + $this->getEntityManager()->clear(); + } +} diff --git a/module/Common/test/ConfigProviderTest.php b/module/Common/test/ConfigProviderTest.php index 05b5da49..d8d9dbd7 100644 --- a/module/Common/test/ConfigProviderTest.php +++ b/module/Common/test/ConfigProviderTest.php @@ -1,4 +1,6 @@ configProvider->__invoke(); $this->assertArrayHasKey('dependencies', $config); - $this->assertArrayHasKey('twig', $config); + $this->assertArrayHasKey('plates', $config); } } diff --git a/module/Common/test/Factory/CacheFactoryTest.php b/module/Common/test/Factory/CacheFactoryTest.php index 58542426..0da1b694 100644 --- a/module/Common/test/Factory/CacheFactoryTest.php +++ b/module/Common/test/Factory/CacheFactoryTest.php @@ -1,4 +1,6 @@ '1.2.3.4', - 'port' => 123 + 'port' => 123, ], [ 'host' => '4.3.2.1', - 'port' => 321 + 'port' => 321, ], ]; /** @var MemcachedCache $instance */ diff --git a/module/Common/test/Factory/DottedAccessConfigAbstractFactoryTest.php b/module/Common/test/Factory/DottedAccessConfigAbstractFactoryTest.php new file mode 100644 index 00000000..59bc08e3 --- /dev/null +++ b/module/Common/test/Factory/DottedAccessConfigAbstractFactoryTest.php @@ -0,0 +1,91 @@ +factory = new DottedAccessConfigAbstractFactory(); + } + + /** + * @param string $serviceName + * @param bool $canCreate + * + * @test + * @dataProvider provideDotNames + */ + public function canCreateOnlyServicesWithDot(string $serviceName, bool $canCreate) + { + $this->assertEquals($canCreate, $this->factory->canCreate(new ServiceManager(), $serviceName)); + } + + public function provideDotNames(): array + { + return [ + ['foo.bar', true], + ['config.something', true], + ['config_something', false], + ['foo', false], + ]; + } + + /** + * @test + */ + public function throwsExceptionWhenFirstPartOfTheServiceIsNotRegistered() + { + $this->expectException(ServiceNotCreatedException::class); + $this->expectExceptionMessage( + 'Defined service "foo" could not be found in container after resolving dotted expression "foo.bar"' + ); + + $this->factory->__invoke(new ServiceManager(), 'foo.bar'); + } + + /** + * @test + */ + public function dottedNotationIsRecursivelyResolvedUntilLastValueIsFoundAndReturned() + { + $expected = 'this is the result'; + + $result = $this->factory->__invoke(new ServiceManager(['services' => [ + 'foo' => [ + 'bar' => ['baz' => $expected], + ], + ]]), 'foo.bar.baz'); + + $this->assertEquals($expected, $result); + } + + /** + * @test + */ + public function exceptionIsThrownIfAnyStepCannotBeResolved() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The key "baz" provided in the dotted notation could not be found in the array service' + ); + + $this->factory->__invoke(new ServiceManager(['services' => [ + 'foo' => [ + 'bar' => ['something' => 123], + ], + ]]), 'foo.bar.baz'); + } +} diff --git a/module/Common/test/Factory/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php b/module/Common/test/Factory/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php index aca35f62..dd4b6301 100644 --- a/module/Common/test/Factory/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Common/test/Factory/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -1,4 +1,6 @@ function () { return $this->image->reveal(); }, - ] + ], ]), $this->filesystem->reveal(), 'dir'); } diff --git a/module/Common/test/Template/Extension/TranslatorExtensionTest.php b/module/Common/test/Template/Extension/TranslatorExtensionTest.php new file mode 100644 index 00000000..04d36188 --- /dev/null +++ b/module/Common/test/Template/Extension/TranslatorExtensionTest.php @@ -0,0 +1,36 @@ +extension = new TranslatorExtension($this->prophesize(Translator::class)->reveal()); + } + + /** + * @test + */ + public function properFunctionsAreReturned() + { + $engine = $this->prophesize(Engine::class); + + $engine->registerFunction('translate', Argument::type('callable'))->shouldBeCalledTimes(1); + $engine->registerFunction('translate_plural', Argument::type('callable'))->shouldBeCalledTimes(1); + + $funcs = $this->extension->register($engine->reveal()); + } +} diff --git a/module/Common/test/Twig/Extension/TranslatorExtensionTest.php b/module/Common/test/Twig/Extension/TranslatorExtensionTest.php deleted file mode 100644 index 46dc2ec8..00000000 --- a/module/Common/test/Twig/Extension/TranslatorExtensionTest.php +++ /dev/null @@ -1,70 +0,0 @@ -translator = $this->prophesize(Translator::class); - $this->extension = new TranslatorExtension($this->translator->reveal()); - } - - /** - * @test - */ - public function extensionNameIsClassName() - { - $this->assertEquals(TranslatorExtension::class, $this->extension->getName()); - } - - /** - * @test - */ - public function properFunctionsAreReturned() - { - $funcs = $this->extension->getFunctions(); - $this->assertCount(2, $funcs); - foreach ($funcs as $func) { - $this->assertInstanceOf(\Twig_SimpleFunction::class, $func); - } - } - - /** - * @test - */ - public function translateFallbacksToTranslator() - { - $this->translator->translate('foo', 'default', null)->shouldBeCalledTimes(1); - $this->extension->translate('foo'); - - $this->translator->translate('bar', 'baz', 'en')->shouldBeCalledTimes(1); - $this->extension->translate('bar', 'baz', 'en'); - } - - /** - * @test - */ - public function translatePluralFallbacksToTranslator() - { - $this->translator->translatePlural('foo', 'bar', 'baz', 'default', null)->shouldBeCalledTimes(1); - $this->extension->translatePlural('foo', 'bar', 'baz'); - - $this->translator->translatePlural('foo', 'bar', 'baz', 'another', 'en')->shouldBeCalledTimes(1); - $this->extension->translatePlural('foo', 'bar', 'baz', 'another', 'en'); - } -} diff --git a/module/Common/test/Util/DateRangeTest.php b/module/Common/test/Util/DateRangeTest.php index 261e047d..4cde5004 100644 --- a/module/Common/test/Util/DateRangeTest.php +++ b/module/Common/test/Util/DateRangeTest.php @@ -1,4 +1,6 @@ [], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 1ef7257c..31a4619a 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -1,29 +1,64 @@ [ 'factories' => [ Options\AppOptions::class => Options\AppOptionsFactory::class, + NotFoundDelegate::class => ConfigAbstractFactory::class, // Services - Service\UrlShortener::class => AnnotatedFactory::class, - Service\VisitsTracker::class => AnnotatedFactory::class, - Service\ShortUrlService::class => AnnotatedFactory::class, - Service\VisitService::class => AnnotatedFactory::class, - Service\Tag\TagService::class => AnnotatedFactory::class, + Service\UrlShortener::class => ConfigAbstractFactory::class, + Service\VisitsTracker::class => ConfigAbstractFactory::class, + Service\ShortUrlService::class => ConfigAbstractFactory::class, + Service\VisitService::class => ConfigAbstractFactory::class, + Service\Tag\TagService::class => ConfigAbstractFactory::class, // Middleware - Action\RedirectAction::class => AnnotatedFactory::class, - Action\QrCodeAction::class => AnnotatedFactory::class, - Action\PreviewAction::class => AnnotatedFactory::class, - Middleware\QrCodeCacheMiddleware::class => AnnotatedFactory::class, + Action\RedirectAction::class => ConfigAbstractFactory::class, + Action\QrCodeAction::class => ConfigAbstractFactory::class, + Action\PreviewAction::class => ConfigAbstractFactory::class, + Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + 'Zend\Expressive\Delegate\DefaultDelegate' => NotFoundDelegate::class, ], ], + ConfigAbstractFactory::class => [ + NotFoundDelegate::class => [TemplateRendererInterface::class], + + // Services + Service\UrlShortener::class => [ + 'httpClient', + 'em', + Cache::class, + 'config.url_shortener.validate_url', + 'config.url_shortener.shortcode_chars', + ], + Service\VisitsTracker::class => ['em'], + Service\ShortUrlService::class => ['em'], + Service\VisitService::class => ['em'], + Service\Tag\TagService::class => ['em'], + + // Middleware + Action\RedirectAction::class => [Service\UrlShortener::class, Service\VisitsTracker::class], + Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'], + Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class], + Middleware\QrCodeCacheMiddleware::class => [Cache::class], + ], + ]; diff --git a/module/Core/config/entity-manager.config.php b/module/Core/config/entity-manager.config.php index e9359519..e5008568 100644 --- a/module/Core/config/entity-manager.config.php +++ b/module/Core/config/entity-manager.config.php @@ -1,4 +1,6 @@ [ diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php index a3c70aeb..e18cb448 100644 --- a/module/Core/config/routes.config.php +++ b/module/Core/config/routes.config.php @@ -1,4 +1,6 @@ [ 'paths' => [ - 'module/Core/templates', + 'ShlinkCore' => __DIR__ . '/../templates', ], ], diff --git a/module/Core/config/translator.config.php b/module/Core/config/translator.config.php index ae120db3..659d4cae 100644 --- a/module/Core/config/translator.config.php +++ b/module/Core/config/translator.config.php @@ -1,4 +1,6 @@ [ diff --git a/module/Core/config/zend-expressive.config.php b/module/Core/config/zend-expressive.config.php index c5fefe8f..81f6676f 100644 --- a/module/Core/config/zend-expressive.config.php +++ b/module/Core/config/zend-expressive.config.php @@ -1,11 +1,12 @@ [ 'error_handler' => [ - 'template_404' => 'core/error/404.html.twig', - 'template_error' => 'core/error/error.html.twig', + 'template_404' => 'ShlinkCore::error/404', + 'template_error' => 'ShlinkCore::error/error', ], ], diff --git a/module/Core/lang/es.mo b/module/Core/lang/es.mo index efc80eca..d1a628d4 100644 Binary files a/module/Core/lang/es.mo and b/module/Core/lang/es.mo differ diff --git a/module/Core/lang/es.po b/module/Core/lang/es.po index 3a3d4809..9a5d4c45 100644 --- a/module/Core/lang/es.po +++ b/module/Core/lang/es.po @@ -1,15 +1,15 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-08-21 18:17+0200\n" -"PO-Revision-Date: 2016-08-21 18:17+0200\n" +"POT-Creation-Date: 2017-10-13 12:29+0200\n" +"PO-Revision-Date: 2017-10-13 12:30+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 1.8.7.1\n" +"X-Generator: Poedit 2.0.1\n" "X-Poedit-Basepath: ..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -18,18 +18,24 @@ msgstr "" "X-Poedit-SearchPath-1: config\n" "X-Poedit-SearchPath-2: src\n" -msgid "Make sure you included all the characters, with no extra punctuation." -msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra." +msgid "URL Not Found" +msgstr "URL no encontrada" + +msgid "Page not found." +msgstr "Página no encontrada." + +msgid "The page you requested could not be found." +msgstr "La página solicitada no ha podido ser encontrada." msgid "Oops!" msgstr "¡Vaya!" -msgid "This short URL doesn't seem to be valid." -msgstr "Esta URL acortada no parece ser válida." - -msgid "URL Not Found" -msgstr "URL no encontrada" - #, php-format msgid "We encountered a %s %s error." msgstr "Hemos encontrado un error %s %s." + +msgid "This short URL doesn't seem to be valid." +msgstr "Esta URL acortada no parece ser válida." + +msgid "Make sure you included all the characters, with no extra punctuation." +msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra." diff --git a/module/Core/src/Action/PreviewAction.php b/module/Core/src/Action/PreviewAction.php index 291225be..0e1043a0 100644 --- a/module/Core/src/Action/PreviewAction.php +++ b/module/Core/src/Action/PreviewAction.php @@ -1,21 +1,24 @@ previewGenerator = $previewGenerator; @@ -54,14 +50,14 @@ class PreviewAction implements MiddlewareInterface try { $url = $this->urlShortener->shortCodeToUrl($shortCode); - if (! isset($url)) { - return $delegate->process($request); - } - $imagePath = $this->previewGenerator->generatePreview($url); return $this->generateImageResponse($imagePath); } catch (InvalidShortCodeException $e) { - return $delegate->process($request); + return $this->buildErrorResponse($request, $delegate); + } catch (EntityDoesNotExistException $e) { + return $this->buildErrorResponse($request, $delegate); + } catch (PreviewGenerationException $e) { + return $this->buildErrorResponse($request, $delegate); } } } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 3970d740..30363619 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -1,7 +1,8 @@ getAttribute('shortCode'); try { - $shortUrl = $this->urlShortener->shortCodeToUrl($shortCode); - if ($shortUrl === null) { - return $delegate->process($request); - } + $this->urlShortener->shortCodeToUrl($shortCode); } catch (InvalidShortCodeException $e) { $this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e); - return $delegate->process($request); + return $this->buildErrorResponse($request, $delegate); + } catch (EntityDoesNotExistException $e) { + $this->logger->warning('Tried to create a QR code with a not found short code' . PHP_EOL . $e); + return $this->buildErrorResponse($request, $delegate); } $path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]); $size = $this->getSizeParam($request); - $qrCode = new QrCode($request->getUri()->withPath($path)->withQuery('')); + $qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery('')); $qrCode->setSize($size) ->setPadding(0); return new QrCodeResponse($qrCode); diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index d8b4a04e..e22639c7 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -1,21 +1,23 @@ urlShortener = $urlShortener; $this->visitTracker = $visitTracker; - $this->logger = $logger ?: new NullLogger(); } /** @@ -75,10 +61,10 @@ class RedirectAction implements MiddlewareInterface // Return a redirect response to the long URL. // Use a temporary redirect to make sure browsers always hit the server for analytics purposes return new RedirectResponse($longUrl); - } catch (\Exception $e) { - // In case of error, dispatch 404 error - $this->logger->error('Error redirecting to long URL.' . PHP_EOL . $e); - return $delegate->process($request); + } catch (InvalidShortCodeException $e) { + return $this->buildErrorResponse($request, $delegate); + } catch (EntityDoesNotExistException $e) { + return $this->buildErrorResponse($request, $delegate); } } } diff --git a/module/Core/src/Action/Util/ErrorResponseBuilderTrait.php b/module/Core/src/Action/Util/ErrorResponseBuilderTrait.php new file mode 100644 index 00000000..57380b8b --- /dev/null +++ b/module/Core/src/Action/Util/ErrorResponseBuilderTrait.php @@ -0,0 +1,18 @@ +withAttribute(NotFoundDelegate::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code'); + return $delegate->process($request); + } +} diff --git a/module/Core/src/ConfigProvider.php b/module/Core/src/ConfigProvider.php index 927e126d..a58f0c55 100644 --- a/module/Core/src/ConfigProvider.php +++ b/module/Core/src/ConfigProvider.php @@ -1,4 +1,6 @@ setDateCreated(new \DateTime()); - $this->setVisits(new ArrayCollection()); - $this->setShortCode(''); + $this->dateCreated = new \DateTime(); + $this->visits = new ArrayCollection(); + $this->shortCode = ''; $this->tags = new ArrayCollection(); } /** * @return string */ - public function getOriginalUrl() + public function getOriginalUrl(): string { return $this->originalUrl; } @@ -76,16 +93,16 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @param string $originalUrl * @return $this */ - public function setOriginalUrl($originalUrl) + public function setOriginalUrl(string $originalUrl) { - $this->originalUrl = (string) $originalUrl; + $this->originalUrl = $originalUrl; return $this; } /** * @return string */ - public function getShortCode() + public function getShortCode(): string { return $this->shortCode; } @@ -94,7 +111,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @param string $shortCode * @return $this */ - public function setShortCode($shortCode) + public function setShortCode(string $shortCode) { $this->shortCode = $shortCode; return $this; @@ -103,7 +120,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable /** * @return \DateTime */ - public function getDateCreated() + public function getDateCreated(): \DateTime { return $this->dateCreated; } @@ -112,34 +129,16 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @param \DateTime $dateCreated * @return $this */ - public function setDateCreated($dateCreated) + public function setDateCreated(\DateTime $dateCreated) { $this->dateCreated = $dateCreated; return $this; } - /** - * @return Visit[]|Collection - */ - public function getVisits() - { - return $this->visits; - } - - /** - * @param Visit[]|Collection $visits - * @return $this - */ - public function setVisits($visits) - { - $this->visits = $visits; - return $this; - } - /** * @return Collection|Tag[] */ - public function getTags() + public function getTags(): Collection { return $this->tags; } @@ -148,7 +147,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @param Collection|Tag[] $tags * @return $this */ - public function setTags($tags) + public function setTags(Collection $tags) { $this->tags = $tags; return $this; @@ -164,6 +163,81 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable return $this; } + /** + * @return \DateTime|null + */ + public function getValidSince() + { + return $this->validSince; + } + + /** + * @param \DateTime|null $validSince + * @return $this|self + */ + public function setValidSince($validSince): self + { + $this->validSince = $validSince; + return $this; + } + + /** + * @return \DateTime|null + */ + public function getValidUntil() + { + return $this->validUntil; + } + + /** + * @param \DateTime|null $validUntil + * @return $this|self + */ + public function setValidUntil($validUntil): self + { + $this->validUntil = $validUntil; + return $this; + } + + public function getVisitsCount(): int + { + return count($this->visits); + } + + /** + * @param Collection $visits + * @return ShortUrl + * @internal + */ + public function setVisits(Collection $visits): self + { + $this->visits = $visits; + return $this; + } + + /** + * @return int|null + */ + public function getMaxVisits() + { + return $this->maxVisits; + } + + /** + * @param int|null $maxVisits + * @return $this|self + */ + public function setMaxVisits($maxVisits): self + { + $this->maxVisits = $maxVisits; + return $this; + } + + public function maxVisitsReached(): bool + { + return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits; + } + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php @@ -176,8 +250,8 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable return [ 'shortCode' => $this->shortCode, 'originalUrl' => $this->originalUrl, - 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ATOM) : null, - 'visitsCount' => count($this->visits), + 'dateCreated' => $this->dateCreated !== null ? $this->dateCreated->format(\DateTime::ATOM) : null, + 'visitsCount' => $this->getVisitsCount(), 'tags' => $this->tags->toArray(), ]; } diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php index 7537b8c0..cd895268 100644 --- a/module/Core/src/Entity/Tag.php +++ b/module/Core/src/Entity/Tag.php @@ -1,4 +1,6 @@ cache = $cache; diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index cce854a2..edb46f08 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -1,4 +1,6 @@ createListQueryBuilder($searchTerm, $tags); $qb->select('s'); @@ -43,22 +50,14 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI $fieldName = is_array($orderBy) ? key($orderBy) : $orderBy; $order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC'; - if (in_array($fieldName, [ - 'visits', - 'visitsCount', - 'visitCount', - ], true)) { + if (in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) { $qb->addSelect('COUNT(v) AS totalVisits') ->leftJoin('s.visits', 'v') ->groupBy('s') ->orderBy('totalVisits', $order); return array_column($qb->getQuery()->getResult(), 0); - } elseif (in_array($fieldName, [ - 'originalUrl', - 'shortCode', - 'dateCreated', - ], true)) { + } elseif (in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) { $qb->orderBy('s.' . $fieldName, $order); } @@ -72,7 +71,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI * @param array $tags * @return int */ - public function countList($searchTerm = null, array $tags = []) + public function countList(string $searchTerm = null, array $tags = []): int { $qb = $this->createListQueryBuilder($searchTerm, $tags); $qb->select('COUNT(s)'); @@ -85,7 +84,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI * @param array $tags * @return QueryBuilder */ - protected function createListQueryBuilder($searchTerm = null, array $tags = []) + protected function createListQueryBuilder(string $searchTerm = null, array $tags = []): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's'); @@ -115,4 +114,31 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI return $qb; } + + /** + * @param string $shortCode + * @return ShortUrl|null + */ + public function findOneByShortCode(string $shortCode) + { + $now = new \DateTimeImmutable(); + + $qb = $this->createQueryBuilder('s'); + $qb->where($qb->expr()->eq('s.shortCode', ':shortCode')) + ->setParameter('shortCode', $shortCode) + ->andWhere($qb->expr()->orX( + $qb->expr()->lte('s.validSince', ':now'), + $qb->expr()->isNull('s.validSince') + )) + ->andWhere($qb->expr()->orX( + $qb->expr()->gte('s.validUntil', ':now'), + $qb->expr()->isNull('s.validUntil') + )) + ->setParameter('now', $now) + ->setMaxResults(1); + + /** @var ShortUrl|null $result */ + $result = $qb->getQuery()->getOneOrNullResult(); + return $result === null || $result->maxVisitsReached() ? null : $result; + } } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 5096e065..bff0d723 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -1,9 +1,17 @@ createQueryBuilder('v'); $qb->where($qb->expr()->isNull('v.visitLocation')); @@ -20,15 +22,20 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa } /** - * @param ShortUrl|int $shortUrl + * @param ShortUrl|int $shortUrlOrId * @param DateRange|null $dateRange * @return Visit[] */ - public function findVisitsByShortUrl($shortUrl, DateRange $dateRange = null) + public function findVisitsByShortUrl($shortUrlOrId, DateRange $dateRange = null): array { - $shortUrl = $shortUrl instanceof ShortUrl - ? $shortUrl - : $this->getEntityManager()->find(ShortUrl::class, $shortUrl); + /** @var ShortUrl|null $shortUrl */ + $shortUrl = $shortUrlOrId instanceof ShortUrl + ? $shortUrlOrId + : $this->getEntityManager()->find(ShortUrl::class, $shortUrlOrId); + + if ($shortUrl === null) { + return []; + } $qb = $this->createQueryBuilder('v'); $qb->where($qb->expr()->eq('v.shortUrl', ':shortUrl')) @@ -36,11 +43,11 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa ->orderBy('v.date', 'DESC') ; // Apply date range filtering - if (! empty($dateRange->getStartDate())) { + if ($dateRange !== null && $dateRange->getStartDate() !== null) { $qb->andWhere($qb->expr()->gte('v.date', ':startDate')) ->setParameter('startDate', $dateRange->getStartDate()); } - if (! empty($dateRange->getEndDate())) { + if ($dateRange !== null && $dateRange->getEndDate() !== null) { $qb->andWhere($qb->expr()->lte('v.date', ':endDate')) ->setParameter('endDate', $dateRange->getEndDate()); } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index c65f495d..4ee3ebde 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -1,4 +1,6 @@ renderer = $renderer; + $this->defaultTemplate = $defaultTemplate; + } + + /** + * Dispatch the next available middleware and return the response. + * + * @param ServerRequestInterface $request + * + * @return ResponseInterface + * @throws \InvalidArgumentException + */ + public function process(ServerRequestInterface $request): ResponseInterface + { + $accepts = explode(',', $request->getHeaderLine('Accept')); + $accept = array_shift($accepts); + $status = StatusCodeInterface::STATUS_NOT_FOUND; + + // If the first accepted type is json, return a json response + if (in_array($accept, ['application/json', 'text/json', 'application/x-json'], true)) { + return new Response\JsonResponse([ + 'error' => 'NOT_FOUND', + 'message' => 'Not found', + ], $status); + } + + $notFoundTemplate = $request->getAttribute(self::NOT_FOUND_TEMPLATE, $this->defaultTemplate); + return new Response\HtmlResponse($this->renderer->render($notFoundTemplate, ['request' => $request]), $status); + } +} diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 59c76565..2e22717b 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -1,7 +1,8 @@ em = $em; @@ -60,7 +55,7 @@ class ShortUrlService implements ShortUrlServiceInterface $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, ]); - if (! isset($shortUrl)) { + if ($shortUrl === null) { throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); } diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index bc9b8daf..e299842d 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -1,4 +1,6 @@ em = $em; diff --git a/module/Core/src/Service/Tag/TagServiceInterface.php b/module/Core/src/Service/Tag/TagServiceInterface.php index 48714309..7ee57bda 100644 --- a/module/Core/src/Service/Tag/TagServiceInterface.php +++ b/module/Core/src/Service/Tag/TagServiceInterface.php @@ -1,4 +1,6 @@ httpClient = $httpClient; $this->em = $em; - $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; $this->cache = $cache; $this->urlValidationEnabled = $urlValidationEnabled; + $this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars; + $this->slugger = $slugger ?: new Slugify(); } /** @@ -76,17 +72,28 @@ class UrlShortener implements UrlShortenerInterface * * @param UriInterface $url * @param string[] $tags + * @param \DateTime|null $validSince + * @param \DateTime|null $validUntil + * @param string|null $customSlug + * @param int|null $maxVisits * @return string + * @throws NonUniqueSlugException * @throws InvalidUrlException * @throws RuntimeException */ - public function urlToShortCode(UriInterface $url, array $tags = []) - { + public function urlToShortCode( + UriInterface $url, + array $tags = [], + \DateTime $validSince = null, + \DateTime $validUntil = null, + string $customSlug = null, + int $maxVisits = null + ): string { // If the url already exists in the database, just return its short code $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'originalUrl' => $url, ]); - if (isset($shortUrl)) { + if ($shortUrl !== null) { return $shortUrl->getShortCode(); } @@ -95,6 +102,7 @@ class UrlShortener implements UrlShortenerInterface // Check that the URL exists $this->checkUrlExists($url); } + $customSlug = $this->processCustomSlug($customSlug); // Transactionally insert the short url, then generate the short code and finally update the short code try { @@ -102,12 +110,15 @@ class UrlShortener implements UrlShortenerInterface // First, create the short URL with an empty short code $shortUrl = new ShortUrl(); - $shortUrl->setOriginalUrl($url); + $shortUrl->setOriginalUrl((string) $url) + ->setValidSince($validSince) + ->setValidUntil($validUntil) + ->setMaxVisits($maxVisits); $this->em->persist($shortUrl); $this->em->flush(); // Generate the short code and persist it - $shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId()); + $shortCode = $customSlug ?? $this->convertAutoincrementIdToShortCode($shortUrl->getId()); $shortUrl->setShortCode($shortCode) ->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); @@ -128,9 +139,9 @@ class UrlShortener implements UrlShortenerInterface * Tries to perform a GET request to provided url, returning true on success and false on failure * * @param UriInterface $url - * @return bool + * @return void */ - protected function checkUrlExists(UriInterface $url) + private function checkUrlExists(UriInterface $url) { try { $this->httpClient->request('GET', $url, ['allow_redirects' => [ @@ -147,29 +158,46 @@ class UrlShortener implements UrlShortenerInterface * @param int $id * @return string */ - protected function convertAutoincrementIdToShortCode($id) + private function convertAutoincrementIdToShortCode($id): string { - $id = intval($id) + 200000; // Increment the Id so that the generated shortcode is not too short + $id = ((int) $id) + 200000; // Increment the Id so that the generated shortcode is not too short $length = strlen($this->chars); $code = ''; while ($id > 0) { // Determine the value of the next higher character in the short code and prepend it - $code = $this->chars[intval(fmod($id, $length))] . $code; + $code = $this->chars[(int) fmod($id, $length)] . $code; $id = floor($id / $length); } - return $this->chars[intval($id)] . $code; + return $this->chars[(int) $id] . $code; + } + + private function processCustomSlug($customSlug) + { + if ($customSlug === null) { + return null; + } + + // If a custom slug was provided, check it is unique + $customSlug = $this->slugger->slugify($customSlug); + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy(['shortCode' => $customSlug]); + if ($shortUrl !== null) { + throw NonUniqueSlugException::fromSlug($customSlug); + } + + return $customSlug; } /** * Tries to find the mapped URL for provided short code. Returns null if not found * * @param string $shortCode - * @return string|null + * @return string * @throws InvalidShortCodeException + * @throws EntityDoesNotExistException */ - public function shortCodeToUrl($shortCode) + public function shortCodeToUrl(string $shortCode): string { $cacheKey = sprintf('%s_longUrl', $shortCode); // Check if the short code => URL map is already cached @@ -178,21 +206,22 @@ class UrlShortener implements UrlShortenerInterface } // Validate short code format - if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) { + if (! preg_match('|[' . $this->chars . ']+|', $shortCode)) { throw InvalidShortCodeException::fromCharset($shortCode, $this->chars); } - /** @var ShortUrl $shortUrl */ - $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ - 'shortCode' => $shortCode, - ]); - // Cache the shortcode - if (isset($shortUrl)) { - $url = $shortUrl->getOriginalUrl(); - $this->cache->save($cacheKey, $url); - return $url; + /** @var ShortUrlRepository $shortUrlRepo */ + $shortUrlRepo = $this->em->getRepository(ShortUrl::class); + $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode); + if ($shortUrl === null) { + throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [ + 'shortCode' => $shortCode, + ]); } - return null; + // Cache the shortcode + $url = $shortUrl->getOriginalUrl(); + $this->cache->save($cacheKey, $url); + return $url; } } diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 4ddf4b7b..17859a81 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -1,10 +1,14 @@ em = $em; diff --git a/module/Core/src/Service/VisitServiceInterface.php b/module/Core/src/Service/VisitServiceInterface.php index 8347fdb3..46f6ceb7 100644 --- a/module/Core/src/Service/VisitServiceInterface.php +++ b/module/Core/src/Service/VisitServiceInterface.php @@ -1,4 +1,6 @@ em = $em; @@ -46,24 +42,25 @@ class VisitsTracker implements VisitsTrackerInterface ->setUserAgent($request->getHeaderLine('User-Agent')) ->setReferer($request->getHeaderLine('Referer')) ->setRemoteAddr($this->findOutRemoteAddr($request)); + $this->em->persist($visit); - $this->em->flush(); + $this->em->flush($visit); } /** * @param ServerRequestInterface $request - * @return string + * @return string|null */ - protected function findOutRemoteAddr(ServerRequestInterface $request) + private function findOutRemoteAddr(ServerRequestInterface $request) { $forwardedFor = $request->getHeaderLine('X-Forwarded-For'); if (empty($forwardedFor)) { $serverParams = $request->getServerParams(); - return isset($serverParams['REMOTE_ADDR']) ? $serverParams['REMOTE_ADDR'] : null; + return $serverParams['REMOTE_ADDR'] ?? null; } $ips = explode(',', $forwardedFor); - return $ips[0]; + return $ips[0] ?? null; } /** @@ -72,14 +69,15 @@ class VisitsTracker implements VisitsTrackerInterface * @param $shortCode * @param DateRange $dateRange * @return Visit[] + * @throws InvalidArgumentException */ - public function info($shortCode, DateRange $dateRange = null) + public function info($shortCode, DateRange $dateRange = null): array { /** @var ShortUrl $shortUrl */ $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ 'shortCode' => $shortCode, ]); - if (! isset($shortUrl)) { + if ($shortUrl === null) { throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode)); } diff --git a/module/Core/src/Service/VisitsTrackerInterface.php b/module/Core/src/Service/VisitsTrackerInterface.php index 6aecabe4..d3295f37 100644 --- a/module/Core/src/Service/VisitsTrackerInterface.php +++ b/module/Core/src/Service/VisitsTrackerInterface.php @@ -1,7 +1,10 @@ - p {margin-bottom: 20px;} - body {text-align: center;} - -{% endblock %} - -{% block content %} -

{{ translate('Oops!') }}

-
-

{{ translate('This short URL doesn\'t seem to be valid.') }}

-

{{ translate('Make sure you included all the characters, with no extra punctuation.') }}

-{% endblock %} diff --git a/module/Core/templates/core/error/error.html.twig b/module/Core/templates/core/error/error.html.twig deleted file mode 100644 index 5cb66c57..00000000 --- a/module/Core/templates/core/error/error.html.twig +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'core/layout/default.html.twig' %} - -{% block title %}{{ status }} {{ reason }}{% endblock %} - -{% block stylesheets %} - -{% endblock %} - -{% block content %} -

{{ translate('Oops!') }}

-
- {% if status != 404 %} -

{{ translate('We encountered a %s %s error.') | format(status, reason) }}

- {% else %} -

{{ translate('This short URL doesn\'t seem to be valid.') }}

-

{{ translate('Make sure you included all the characters, with no extra punctuation.') }}

- {% endif %} -{% endblock %} diff --git a/module/Core/templates/error/404.phtml b/module/Core/templates/error/404.phtml new file mode 100644 index 00000000..94f42c05 --- /dev/null +++ b/module/Core/templates/error/404.phtml @@ -0,0 +1,19 @@ +layout('ShlinkCore::layout/default') ?> + +start('title') ?> + translate('URL Not Found') ?> +end() ?> + +start('stylesheets') ?> + +end() ?> + +start('main') ?> +

404

+
+

translate('Page not found.') ?>

+

translate('The page you requested could not be found.') ?>

+end() ?> diff --git a/module/Core/templates/error/error.phtml b/module/Core/templates/error/error.phtml new file mode 100644 index 00000000..6d22ed9f --- /dev/null +++ b/module/Core/templates/error/error.phtml @@ -0,0 +1,25 @@ +layout('ShlinkCore::layout/default') ?> + +start('title') ?> + e($status . ' ' . $reason) ?> +end() ?> + +start('stylesheets') ?> + +end() ?> + +start('main') ?> +

translate('Oops!') ?>

+
+ + +

translate('We encountered a %s %s error.'), $status, $reason) ?>

+ +

translate('This short URL doesn\'t seem to be valid.') ?>

+

translate('Make sure you included all the characters, with no extra punctuation.') ?>

+ +end() ?> + diff --git a/module/Core/templates/invalid-short-code.phtml b/module/Core/templates/invalid-short-code.phtml new file mode 100644 index 00000000..369a168b --- /dev/null +++ b/module/Core/templates/invalid-short-code.phtml @@ -0,0 +1,19 @@ +layout('ShlinkCore::layout/default') ?> + +start('title') ?> + translate('URL Not Found') ?> +end() ?> + +start('stylesheets') ?> + +end() ?> + +start('main') ?> +

translate('Oops!') ?>

+
+

translate('This short URL doesn\'t seem to be valid.') ?>

+

translate('Make sure you included all the characters, with no extra punctuation.') ?>

+end() ?> diff --git a/module/Core/templates/core/layout/default.html.twig b/module/Core/templates/layout/default.phtml similarity index 70% rename from module/Core/templates/core/layout/default.html.twig rename to module/Core/templates/layout/default.phtml index 4b405c5b..802bf466 100644 --- a/module/Core/templates/core/layout/default.html.twig +++ b/module/Core/templates/layout/default.phtml @@ -3,7 +3,7 @@ - {% block title %}{% endblock %} | URL shortener + <?= $this->section('title', '') ?> | URL shortener @@ -13,28 +13,30 @@ .app-content {flex: 1;} .app-footer p {margin-bottom: 20px;} - {% block stylesheets %}{% endblock %} + section('stylesheets', '') ?>
- {% block content %}{% endblock %} + section('main', '') ?>

- {% block footer %} + section('footer')): ?> + section('footer') ?> +

- © {{ "now" | date("Y") }} Shlink + © Shlink

- {% endblock %} +
- {% block javascript %}{% endblock %} + section('javascript', '') ?> diff --git a/module/Core/test-func/Repository/ShortUrlRepositoryTest.php b/module/Core/test-func/Repository/ShortUrlRepositoryTest.php new file mode 100644 index 00000000..4616f6c8 --- /dev/null +++ b/module/Core/test-func/Repository/ShortUrlRepositoryTest.php @@ -0,0 +1,82 @@ +repo = $this->getEntityManager()->getRepository(ShortUrl::class); + } + + /** + * @test + */ + public function findOneByShortCodeReturnsProperData() + { + $foo = new ShortUrl(); + $foo->setOriginalUrl('foo') + ->setShortCode('foo'); + $this->getEntityManager()->persist($foo); + + $bar = new ShortUrl(); + $bar->setOriginalUrl('bar') + ->setShortCode('bar') + ->setValidSince((new \DateTime())->add(new \DateInterval('P1M'))); + $this->getEntityManager()->persist($bar); + + $visits = []; + for ($i = 0; $i < 3; $i++) { + $visit = new Visit(); + $this->getEntityManager()->persist($visit); + $visits[] = $visit; + } + $baz = new ShortUrl(); + $baz->setOriginalUrl('baz') + ->setShortCode('baz') + ->setVisits(new ArrayCollection($visits)) + ->setMaxVisits(3); + $this->getEntityManager()->persist($baz); + + $this->getEntityManager()->flush(); + + $this->assertSame($foo, $this->repo->findOneByShortCode($foo->getShortCode())); + $this->assertNull($this->repo->findOneByShortCode('invalid')); + $this->assertNull($this->repo->findOneByShortCode($bar->getShortCode())); + $this->assertNull($this->repo->findOneByShortCode($baz->getShortCode())); + } + + /** + * @test + */ + public function countListReturnsProperNumberOfResults() + { + $count = 5; + for ($i = 0; $i < $count; $i++) { + $this->getEntityManager()->persist( + (new ShortUrl())->setOriginalUrl((string) $i) + ->setShortCode((string) $i) + ); + } + $this->getEntityManager()->flush(); + + $this->assertEquals($count, $this->repo->countList()); + } +} diff --git a/module/Core/test-func/Repository/TagRepositoryTest.php b/module/Core/test-func/Repository/TagRepositoryTest.php new file mode 100644 index 00000000..b6a96d85 --- /dev/null +++ b/module/Core/test-func/Repository/TagRepositoryTest.php @@ -0,0 +1,49 @@ +repo = $this->getEntityManager()->getRepository(Tag::class); + } + + /** + * @test + */ + public function deleteByNameDoesNothingWhenEmptyListIsProvided() + { + $this->assertEquals(0, $this->repo->deleteByName([])); + } + + /** + * @test + */ + public function allTagsWhichMatchNameAreDeleted() + { + $names = ['foo', 'bar', 'baz']; + $toDelete = ['foo', 'baz']; + + foreach ($names as $name) { + $this->getEntityManager()->persist(new Tag($name)); + } + $this->getEntityManager()->flush(); + + $this->assertEquals(2, $this->repo->deleteByName($toDelete)); + } +} diff --git a/module/Core/test-func/Repository/VisitRepositoryTest.php b/module/Core/test-func/Repository/VisitRepositoryTest.php new file mode 100644 index 00000000..b13e4053 --- /dev/null +++ b/module/Core/test-func/Repository/VisitRepositoryTest.php @@ -0,0 +1,80 @@ +repo = $this->getEntityManager()->getRepository(Visit::class); + } + + /** + * @test + */ + public function findUnlocatedVisitsReturnsProperVisits() + { + for ($i = 0; $i < 6; $i++) { + $visit = new Visit(); + + if ($i % 2 === 0) { + $location = new VisitLocation(); + $this->getEntityManager()->persist($location); + $visit->setVisitLocation($location); + } + + $this->getEntityManager()->persist($visit); + } + $this->getEntityManager()->flush(); + + $this->assertCount(3, $this->repo->findUnlocatedVisits()); + } + + /** + * @test + */ + public function findVisitsByShortUrlReturnsProperData() + { + $shortUrl = new ShortUrl(); + $shortUrl->setOriginalUrl(''); + $this->getEntityManager()->persist($shortUrl); + + for ($i = 0; $i < 6; $i++) { + $visit = new Visit(); + $visit->setShortUrl($shortUrl) + ->setDate(new \DateTime('2016-01-0' . ($i + 1))); + + $this->getEntityManager()->persist($visit); + } + $this->getEntityManager()->flush(); + + $this->assertCount(0, $this->repo->findVisitsByShortUrl('invalid')); + $this->assertCount(6, $this->repo->findVisitsByShortUrl($shortUrl->getId())); + $this->assertCount(2, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange( + new \DateTime('2016-01-02'), + new \DateTime('2016-01-03') + ))); + $this->assertCount(4, $this->repo->findVisitsByShortUrl($shortUrl->getId(), new DateRange( + new \DateTime('2016-01-03') + ))); + } +} diff --git a/module/Core/test/Action/PreviewActionTest.php b/module/Core/test/Action/PreviewActionTest.php index fa7a84c0..8817eaa4 100644 --- a/module/Core/test/Action/PreviewActionTest.php +++ b/module/Core/test/Action/PreviewActionTest.php @@ -1,15 +1,20 @@ urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1); + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) + ->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); - $delegate->process(Argument::cetera())->shouldBeCalledTimes(1); + $delegate->process(Argument::cetera())->shouldBeCalledTimes(1) + ->willReturn(new Response()); $this->action->process( ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), @@ -79,12 +86,14 @@ class PreviewActionTest extends TestCase $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) ->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); + /** @var MethodProphecy $process */ + $process = $delegate->process(Argument::any())->willReturn(new Response()); $this->action->process( ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), $delegate->reveal() ); - $delegate->process(Argument::any())->shouldHaveBeenCalledTimes(1); + $process->shouldHaveBeenCalledTimes(1); } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index ea71d855..c071ef7e 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -1,15 +1,19 @@ urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1); + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) + ->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); + $process = $delegate->process(Argument::any())->willReturn(new Response()); $this->action->process( ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), $delegate->reveal() ); - $delegate->process(Argument::any())->shouldHaveBeenCalledTimes(1); + $process->shouldHaveBeenCalledTimes(1); } /** @@ -60,13 +66,15 @@ class QrCodeActionTest extends TestCase $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class) ->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); + /** @var MethodProphecy $process */ + $process = $delegate->process(Argument::any())->willReturn(new Response()); $this->action->process( ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode), $delegate->reveal() ); - $delegate->process(Argument::any())->shouldHaveBeenCalledTimes(1); + $process->shouldHaveBeenCalledTimes(1); } /** @@ -75,7 +83,7 @@ class QrCodeActionTest extends TestCase public function aCorrectRequestReturnsTheQrCodeResponse() { $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(new ShortUrl())->shouldBeCalledTimes(1); + $this->urlShortener->shortCodeToUrl($shortCode)->willReturn('')->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); $resp = $this->action->process( diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 6e629772..a429549f 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -1,11 +1,15 @@ urlShortener->shortCodeToUrl($shortCode)->willReturn(null) - ->shouldBeCalledTimes(1); - $delegate = $this->prophesize(DelegateInterface::class); - - $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); - $this->action->process($request, $delegate->reveal()); - - $delegate->process($request)->shouldHaveBeenCalledTimes(1); - } - - /** - * @test - */ - public function nextMiddlewareIsInvokedIfAnExceptionIsThrown() - { - $shortCode = 'abc123'; - $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(\Exception::class) + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) ->shouldBeCalledTimes(1); $delegate = $this->prophesize(DelegateInterface::class); + /** @var MethodProphecy $process */ + $process = $delegate->process(Argument::any())->willReturn(new Response()); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); $this->action->process($request, $delegate->reveal()); - $delegate->process($request)->shouldHaveBeenCalledTimes(1); + $process->shouldHaveBeenCalledTimes(1); } } diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index ce9dea72..f3633040 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -1,4 +1,6 @@ renderer = $this->prophesize(TemplateRendererInterface::class); + $this->delegate = new NotFoundDelegate($this->renderer->reveal()); + } + + /** + * @param string $expectedResponse + * @param string $accept + * @param int $renderCalls + * + * @test + * @dataProvider provideResponses + */ + public function properResponseTypeIsReturned(string $expectedResponse, string $accept, int $renderCalls) + { + $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', $accept); + /** @var MethodProphecy $render */ + $render = $this->renderer->render(Argument::cetera())->willReturn(''); + + $resp = $this->delegate->process($request); + + $this->assertInstanceOf($expectedResponse, $resp); + $render->shouldHaveBeenCalledTimes($renderCalls); + } + + public function provideResponses(): array + { + return [ + [Response\JsonResponse::class, 'application/json', 0], + [Response\JsonResponse::class, 'text/json', 0], + [Response\JsonResponse::class, 'application/x-json', 0], + [Response\HtmlResponse::class, 'text/html', 1], + ]; + } +} diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index f8b9ced7..ed98a5bc 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -1,4 +1,6 @@ em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->cache = new ArrayCache(); + $this->slugger = $this->prophesize(SlugifyInterface::class); $this->setUrlShortener(false); } @@ -71,7 +82,8 @@ class UrlShortenerTest extends TestCase $this->em->reveal(), $this->cache, $urlValidationEnabled, - UrlShortener::DEFAULT_CHARS + UrlShortener::DEFAULT_CHARS, + $this->slugger->reveal() ); } @@ -130,6 +142,54 @@ class UrlShortenerTest extends TestCase $this->assertEquals($shortUrl->getShortCode(), $shortCode); } + /** + * @test + */ + public function whenCustomSlugIsProvidedItIsUsed() + { + /** @var MethodProphecy $slugify */ + $slugify = $this->slugger->slugify('custom-slug')->willReturnArgument(); + + $this->urlShortener->urlToShortCode( + new Uri('http://foobar.com/12345/hello?foo=bar'), + [], + null, + null, + 'custom-slug' + ); + + $slugify->shouldHaveBeenCalledTimes(1); + } + + /** + * @test + */ + public function exceptionIsThrownWhenNonUniqueSlugIsProvided() + { + /** @var MethodProphecy $slugify */ + $slugify = $this->slugger->slugify('custom-slug')->willReturnArgument(); + + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + /** @var MethodProphecy $findBySlug */ + $findBySlug = $repo->findOneBy(['shortCode' => 'custom-slug'])->willReturn(new ShortUrl()); + $repo->findOneBy(Argument::cetera())->willReturn(null); + /** @var MethodProphecy $getRepo */ + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $slugify->shouldBeCalledTimes(1); + $findBySlug->shouldBeCalledTimes(1); + $getRepo->shouldBeCalled(); + $this->expectException(NonUniqueSlugException::class); + + $this->urlShortener->urlToShortCode( + new Uri('http://foobar.com/12345/hello?foo=bar'), + [], + null, + null, + 'custom-slug' + ); + } + /** * @test */ @@ -141,8 +201,8 @@ class UrlShortenerTest extends TestCase $shortUrl->setShortCode($shortCode) ->setOriginalUrl('expected_url'); - $repo = $this->prophesize(ObjectRepository::class); - $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl); + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $repo->findOneByShortCode($shortCode)->willReturn($shortUrl); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->assertFalse($this->cache->contains($shortCode . '_longUrl')); diff --git a/module/Core/test/Service/VisitServiceTest.php b/module/Core/test/Service/VisitServiceTest.php index e2cd8b23..8c226089 100644 --- a/module/Core/test/Service/VisitServiceTest.php +++ b/module/Core/test/Service/VisitServiceTest.php @@ -1,4 +1,6 @@ em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1); $this->em->persist(Argument::any())->shouldBeCalledTimes(1); - $this->em->flush()->shouldBeCalledTimes(1); + $this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1); $this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals()); } @@ -61,7 +63,7 @@ class VisitsTrackerTest extends TestCase $visit = $args[0]; $test->assertEquals('4.3.2.1', $visit->getRemoteAddr()); })->shouldBeCalledTimes(1); - $this->em->flush()->shouldBeCalledTimes(1); + $this->em->flush(Argument::type(Visit::class))->shouldBeCalledTimes(1); $this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals( ['REMOTE_ADDR' => '1.2.3.4'] diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index a36f7590..760f46c8 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -1,34 +1,63 @@ [ 'factories' => [ - JWTService::class => AnnotatedFactory::class, - Service\ApiKeyService::class => AnnotatedFactory::class, + JWTService::class => ConfigAbstractFactory::class, + ApiKeyService::class => ConfigAbstractFactory::class, - Action\AuthenticateAction::class => AnnotatedFactory::class, - Action\CreateShortcodeAction::class => AnnotatedFactory::class, - Action\ResolveUrlAction::class => AnnotatedFactory::class, - Action\GetVisitsAction::class => AnnotatedFactory::class, - Action\ListShortcodesAction::class => AnnotatedFactory::class, - Action\EditShortcodeTagsAction::class => AnnotatedFactory::class, - Action\Tag\ListTagsAction::class => AnnotatedFactory::class, - Action\Tag\DeleteTagsAction::class => AnnotatedFactory::class, - Action\Tag\CreateTagsAction::class => AnnotatedFactory::class, - Action\Tag\UpdateTagAction::class => AnnotatedFactory::class, + Action\AuthenticateAction::class => ConfigAbstractFactory::class, + Action\CreateShortcodeAction::class => ConfigAbstractFactory::class, + Action\ResolveUrlAction::class => ConfigAbstractFactory::class, + Action\GetVisitsAction::class => ConfigAbstractFactory::class, + Action\ListShortcodesAction::class => ConfigAbstractFactory::class, + Action\EditShortcodeTagsAction::class => ConfigAbstractFactory::class, + Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, + Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, + Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, + Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, - Middleware\BodyParserMiddleware::class => AnnotatedFactory::class, + Middleware\BodyParserMiddleware::class => InvokableFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\PathVersionMiddleware::class => InvokableFactory::class, - Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, + Middleware\CheckAuthenticationMiddleware::class => ConfigAbstractFactory::class, ], ], + ConfigAbstractFactory::class => [ + JWTService::class => [AppOptions::class], + ApiKeyService::class => ['em'], + + Action\AuthenticateAction::class => [ApiKeyService::class, JWTService::class, 'translator', 'Logger_Shlink'], + Action\CreateShortcodeAction::class => [ + Service\UrlShortener::class, + 'translator', + 'config.url_shortener.domain', + 'Logger_Shlink', + ], + Action\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'], + Action\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'], + Action\ListShortcodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'], + Action\EditShortcodeTagsAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'], + Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], + Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], + Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], + Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, Translator::class, LoggerInterface::class], + + Middleware\CheckAuthenticationMiddleware::class => [JWTService::class, 'translator', 'Logger_Shlink'], + ], + ]; diff --git a/module/Rest/config/entity-manager.config.php b/module/Rest/config/entity-manager.config.php index e9359519..e5008568 100644 --- a/module/Rest/config/entity-manager.config.php +++ b/module/Rest/config/entity-manager.config.php @@ -1,4 +1,6 @@ [ diff --git a/module/Rest/config/error-handler.config.php b/module/Rest/config/error-handler.config.php index cb06f6bf..b3df3f7c 100644 --- a/module/Rest/config/error-handler.config.php +++ b/module/Rest/config/error-handler.config.php @@ -1,4 +1,6 @@ [ diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index a7b89c77..f81f6971 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 dc46df88..fa02fd1f 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: 2017-07-16 09:39+0200\n" -"PO-Revision-Date: 2017-07-16 09:40+0200\n" +"POT-Creation-Date: 2017-10-21 20:20+0200\n" +"PO-Revision-Date: 2017-10-21 20:20+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -32,6 +32,10 @@ msgstr "No se ha proporcionado una URL" msgid "Provided URL %s is invalid. Try with a different one." msgstr "La URL proporcionada \"%s\" es inválida. Prueba con una diferente." +#, php-format +msgid "Provided slug %s is already in use. Try with a different one." +msgstr "El slug proporcionado \"%s\" ya está en uso. Prueba con uno diferente." + msgid "Unexpected error occurred" msgstr "Ocurrió un error inesperado" @@ -79,15 +83,3 @@ msgstr "" "No se ha proporcionado token de autenticación o este es inválido. Realiza " "una nueva petición de autenticación y envía el token proporcionado en cada " "nueva petición en la cabecera \"%s\"" - -#~ msgid "You have to provide both \"username\" and \"password\"" -#~ msgstr "Debes proporcionar tanto \"username\" como \"password\"" - -#~ msgid "Invalid username and/or password" -#~ msgstr "Usuario y/o contraseña no válidos" - -#~ msgid "Requested route does not exist." -#~ msgstr "La ruta solicitada no existe." - -#~ msgid "RestToken not found for token \"%s\"" -#~ msgstr "No se ha encontrado un RestToken para el token \"%s\"" diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index 01b8c13d..9ba1e650 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -1,4 +1,6 @@ urlShortener->urlToShortCode(new Uri($longUrl), $tags); + $shortCode = $this->urlShortener->urlToShortCode( + new Uri($longUrl), + (array) ($postData['tags'] ?? []), + $this->getOptionalDate($postData, 'validSince'), + $this->getOptionalDate($postData, 'validUntil'), + $customSlug, + isset($postData['maxVisits']) ? (int) $postData['maxVisits'] : null + ); $shortUrl = (new Uri())->withPath($shortCode) ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); return new JsonResponse([ 'longUrl' => $longUrl, - 'shortUrl' => $shortUrl->__toString(), + 'shortUrl' => (string) $shortUrl, 'shortCode' => $shortCode, ]); } catch (InvalidUrlException $e) { @@ -89,7 +87,16 @@ class CreateShortcodeAction extends AbstractRestAction $longUrl ), ], self::STATUS_BAD_REQUEST); - } catch (\Exception $e) { + } catch (NonUniqueSlugException $e) { + $this->logger->warning('Provided non-unique slug.' . PHP_EOL . $e); + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf( + $this->translator->translate('Provided slug %s is already in use. Try with a different one.'), + $customSlug + ), + ], self::STATUS_BAD_REQUEST); + } catch (\Throwable $e) { $this->logger->error('Unexpected error creating shortcode.' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, @@ -97,4 +104,9 @@ class CreateShortcodeAction extends AbstractRestAction ], self::STATUS_INTERNAL_SERVER_ERROR); } } + + private function getOptionalDate(array $postData, string $fieldName) + { + return isset($postData[$fieldName]) ? new \DateTime($postData[$fieldName]) : null; + } } diff --git a/module/Rest/src/Action/EditShortcodeTagsAction.php b/module/Rest/src/Action/EditShortcodeTagsAction.php index 19f3a29d..b1aa6492 100644 --- a/module/Rest/src/Action/EditShortcodeTagsAction.php +++ b/module/Rest/src/Action/EditShortcodeTagsAction.php @@ -1,13 +1,13 @@ [ 'data' => $visits, - ] + ], ]); } catch (InvalidArgumentException $e) { - $this->logger->warning('Provided nonexistent shortcode'. PHP_EOL . $e); + $this->logger->warning('Provided nonexistent shortcode' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::getRestErrorCodeFromException($e), 'message' => sprintf( @@ -73,7 +65,7 @@ class GetVisitsAction extends AbstractRestAction ), ], self::STATUS_NOT_FOUND); } catch (\Exception $e) { - $this->logger->error('Unexpected error while parsing short code'. PHP_EOL . $e); + $this->logger->error('Unexpected error while parsing short code' . PHP_EOL . $e); return new JsonResponse([ 'error' => RestUtils::UNKNOWN_ERROR, 'message' => $this->translator->translate('Unexpected error occurred'), diff --git a/module/Rest/src/Action/ListShortcodesAction.php b/module/Rest/src/Action/ListShortcodesAction.php index f4bf420b..83007e29 100644 --- a/module/Rest/src/Action/ListShortcodesAction.php +++ b/module/Rest/src/Action/ListShortcodesAction.php @@ -1,13 +1,13 @@ urlShortener->shortCodeToUrl($shortCode); - if ($longUrl === null) { - return new JsonResponse([ - 'error' => RestUtils::INVALID_ARGUMENT_ERROR, - 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), - ], self::STATUS_NOT_FOUND); - } - return new JsonResponse([ 'longUrl' => $longUrl, ]); @@ -73,6 +59,12 @@ class ResolveUrlAction extends AbstractRestAction $shortCode ), ], self::STATUS_BAD_REQUEST); + } catch (EntityDoesNotExistException $e) { + $this->logger->warning('Provided short code couldn\'t be found.' . PHP_EOL . $e); + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), + ], self::STATUS_NOT_FOUND); } catch (\Exception $e) { $this->logger->error('Unexpected error while resolving the URL behind a short code.' . PHP_EOL . $e); return new JsonResponse([ diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index 5496513d..da27547c 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -1,12 +1,12 @@ appOptions = $appOptions; diff --git a/module/Rest/src/Authentication/JWTServiceInterface.php b/module/Rest/src/Authentication/JWTServiceInterface.php index 278e6c67..55c8fb36 100644 --- a/module/Rest/src/Authentication/JWTServiceInterface.php +++ b/module/Rest/src/Authentication/JWTServiceInterface.php @@ -1,4 +1,6 @@ process($request); } diff --git a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php index d7d922ad..b5823f4f 100644 --- a/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/CheckAuthenticationMiddleware.php @@ -1,7 +1,8 @@ getUri(); $path = $uri->getPath(); - // If the path does not begin with the version number, prepend v1 by default for retrocompatibility purposes + // If the path does not begin with the version number, prepend v1 by default for BC compatibility purposes if (strpos($path, '/v') !== 0) { $parts = explode('/', $path); // Remove the first empty part and the diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index c9d7f7fb..e2473f1b 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -1,7 +1,8 @@ em = $em; diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index e1b8ce53..51dadf7a 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -1,4 +1,6 @@ urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) ->willReturn('abc123') ->shouldBeCalledTimes(1); @@ -67,7 +70,7 @@ class CreateShortcodeActionTest extends TestCase */ public function anInvalidUrlReturnsError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) ->willThrow(InvalidUrlException::class) ->shouldBeCalledTimes(1); @@ -79,12 +82,35 @@ class CreateShortcodeActionTest extends TestCase $this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_URL_ERROR) > 0); } + /** + * @test + */ + public function nonUniqueSlugReturnsError() + { + $this->urlShortener->urlToShortCode( + Argument::type(Uri::class), + Argument::type('array'), + null, + null, + 'foo', + Argument::cetera() + )->willThrow(NonUniqueSlugException::class)->shouldBeCalledTimes(1); + + $request = ServerRequestFactory::fromGlobals()->withParsedBody([ + 'longUrl' => 'http://www.domain.com/foo/bar', + 'customSlug' => 'foo', + ]); + $response = $this->action->process($request, TestUtils::createDelegateMock()->reveal()); + $this->assertEquals(400, $response->getStatusCode()); + $this->assertContains(RestUtils::INVALID_SLUG_ERROR, (string) $response->getBody()); + } + /** * @test */ public function aGenericExceptionWillReturnError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array'), Argument::cetera()) ->willThrow(\Exception::class) ->shouldBeCalledTimes(1); diff --git a/module/Rest/test/Action/EditShortcodeTagsActionTest.php b/module/Rest/test/Action/EditShortcodeTagsActionTest.php index 72f1dee9..f90c73f0 100644 --- a/module/Rest/test/Action/EditShortcodeTagsActionTest.php +++ b/module/Rest/test/Action/EditShortcodeTagsActionTest.php @@ -1,4 +1,6 @@ urlShortener->shortCodeToUrl($shortCode)->willReturn(null) + $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class) ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode); diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php index 22592e68..795827fa 100644 --- a/module/Rest/test/Action/Tag/CreateTagsActionTest.php +++ b/module/Rest/test/Action/Tag/CreateTagsActionTest.php @@ -1,4 +1,6 @@ Coding standard + @@ -9,11 +10,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + bin diff --git a/phpunit-func.xml b/phpunit-func.xml new file mode 100644 index 00000000..d125e506 --- /dev/null +++ b/phpunit-func.xml @@ -0,0 +1,15 @@ + + + + ./module/*/test-func + + + + + + ./module/*/src/Repository + ./module/*/src/**/Repository + ./module/*/src/**/**/Repository + + + diff --git a/public/index.php b/public/index.php index 6b310943..3f2c0f84 100644 --- a/public/index.php +++ b/public/index.php @@ -1,9 +1,9 @@ get(Application::class); -$app->run(); +$app = $container->get(Application::class)->run();