diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6acf4e6..38ccb54c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.0 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer ${{ matrix.command }} @@ -45,7 +45,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.0 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -80,7 +80,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1, pdo_sqlsrv-5.10.0 + extensions: openswoole-4.11.0, pdo_sqlsrv-5.10.0 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -115,7 +115,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.0 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index dc9e516a..6d50221a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -20,7 +20,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.0 - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} - if: ${{ matrix.swoole == 'no' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index bdbcc952..5b97ee6c 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -23,7 +23,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.0 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer swagger:inline diff --git a/CHANGELOG.md b/CHANGELOG.md index cebea679..882bd2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.1.0] - 2022-04-23 +### Added +* [#1294](https://github.com/shlinkio/shlink/issues/1294) Allowed to provide a specific domain when importing URLs from YOURLS. +* [#1416](https://github.com/shlinkio/shlink/issues/1416) Added support to import URLs from Kutt.it. +* [#1418](https://github.com/shlinkio/shlink/issues/1418) Added support to customize the timezone used by Shlink, falling back to the default one set in PHP config. + + The timezone can be set via the `TIMEZONE` env var, or using the installer tool. + +* [#1309](https://github.com/shlinkio/shlink/issues/1309) Improved URL importing, ensuring individual errors do not make the whole process fail, and instead, failing URLs are skipped. +* [#1162](https://github.com/shlinkio/shlink/issues/1162) Added new endpoint to get visits by domain. + + The endpoint is `GET /domains/{domain}/visits`, and it has the same capabilities as any other visits endpoint, allowing pagination and filtering. + +### Changed +* [#1359](https://github.com/shlinkio/shlink/issues/1359) Hidden database commands. +* [#1385](https://github.com/shlinkio/shlink/issues/1385) Prevented a big error message from being logged when using Shlink without mercure. +* [#1398](https://github.com/shlinkio/shlink/issues/1398) Increased required mutation score for unit tests to 85%. +* [#1419](https://github.com/shlinkio/shlink/issues/1419) Input dates are now parsed to Shlink's configured timezone or default timezone before using them for database queries. +* [#1428](https://github.com/shlinkio/shlink/issues/1428) Updated native dependencies in docker image and base image to PHP v8.1.5. + +### Deprecated +* [#1340](https://github.com/shlinkio/shlink/issues/1340) Deprecated webhooks. New events will only be added to other real-time updates approaches, and webhooks will be completely removed in Shlink 4.0.0. + +### Removed +* *Nothing* + +### Fixed +* [#1397](https://github.com/shlinkio/shlink/issues/1397) Fixed `db:create` command always reporting the schema exists if the `db:migrate` command has been run before by mistake. +* [#1402](https://github.com/shlinkio/shlink/issues/1402) Fixed the base path getting appended with the default domain by mistake, causing multiple side effects in several places. + + ## [3.0.3] - 2022-02-19 ### Added * *Nothing* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb3e7c83..2024adca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,7 @@ This is a simplified version of the project structure: ``` shlink ├── bin -│ ├── cli -│ ├── install -│ └── update +│ └── cli ├── config │ ├── autoload │ ├── params @@ -75,11 +73,11 @@ shlink The purposes of every folder are: -* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line, while `install` and `update` are helper tools used to install and update shlink when not using the docker image. +* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line. * `config`: Contains application-wide configurations, which are later merged with the ones provided by every module. * `data`: Common runtime-generated git-ignored assets, like logs, caches, etc. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. -* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. +* `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. * `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with openswoole. ## Project tests @@ -125,12 +123,6 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration. * Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible. -> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite). -> -> However, they just need to be created empty, with no tables. Also, once created, they are automatically reset before every new execution. -> -> The testing database is always called `shlink_test`. You can create it using the database client of your choice. [DBeaver](https://dbeaver.io/) is a good multi-platform desktop database client which supports all the engines supported by shlink. - ## Pull request process **Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first. diff --git a/Dockerfile b/Dockerfile index 1d07fd34..0b3ee781 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.1.3-alpine3.15 as base +FROM php:8.1.5-alpine3.15 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV OPENSWOOLE_VERSION 4.9.1 +ENV OPENSWOOLE_VERSION 4.11.0 ENV PDO_SQLSRV_VERSION 5.10.0 ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV LC_ALL "C" diff --git a/README.md b/README.md index df9a04d8..6f4afd37 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: * PHP 8.0 or 8.1 -* The next PHP extensions: json, curl, pdo, intl, gd and gmp. +* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use openswoole. * xml extension is required if you want to generate QR codes in svg format. * sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance. -* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite. -* [Openswoole](https://openswoole.com/) or the web server of your choice with PHP integration (Apache or Nginx recommended). +* MySQL, MariaDB, PostgreSQL, MicrosoftSQL or SQLite. + * You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`. +* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx). ### Download diff --git a/composer.json b/composer.json index 13e03621..2446aace 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "laminas/laminas-config-aggregator": "^1.7", "laminas/laminas-diactoros": "^2.8", "laminas/laminas-inputfilter": "^2.13", - "laminas/laminas-servicemanager": "^3.10", + "laminas/laminas-servicemanager": "^3.11.2", "laminas/laminas-stdlib": "^3.6", "lcobucci/jwt": "^4.1", "league/uri": "^6.4", @@ -50,8 +50,8 @@ "shlinkio/shlink-common": "^4.4", "shlinkio/shlink-config": "^1.6", "shlinkio/shlink-event-dispatcher": "^2.3", - "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "^7.0.2", + "shlinkio/shlink-importer": "dev-main#af0e05e as 3.0", + "shlinkio/shlink-installer": "dev-develop#fbbc8f5 as 7.1", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", @@ -61,11 +61,11 @@ "symfony/string": "^6.0" }, "require-dev": { - "cebe/php-openapi": "^1.5", + "cebe/php-openapi": "^1.7", "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.3.0", - "infection/infection": "^0.26", - "openswoole/ide-helper": "~4.9.1", + "infection/infection": "^0.26.5", + "openswoole/ide-helper": "~4.11.0", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.2", "phpstan/phpstan-doctrine": "^1.0", @@ -74,7 +74,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.2.0", - "shlinkio/shlink-test-utils": "^3.0", + "shlinkio/shlink-test-utils": "^3.0.1", "symfony/var-dumper": "^6.0", "veewee/composer-run-parallel": "^1.1" }, @@ -139,7 +139,7 @@ "test:api": "bin/test/run-api-tests.sh", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", + "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=85", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api", diff --git a/config/autoload/app_options.local.php.dist b/config/autoload/app_options.local.php.dist new file mode 100644 index 00000000..14633a61 --- /dev/null +++ b/config/autoload/app_options.local.php.dist @@ -0,0 +1,11 @@ + [ + 'version' => 'latest', + ], + +]; diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 81f9941a..3cada5db 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -27,6 +27,7 @@ return [ Option\Redirect\Regular404RedirectConfigOption::class, Option\Visit\VisitsThresholdConfigOption::class, Option\BasePathConfigOption::class, + Option\TimezoneConfigOption::class, Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 9d2c423f..987c967e 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -6,7 +6,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use const Shlinkio\Shlink\MIN_TASK_WORKERS; -return (static function () { +return (static function (): array { $taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16); return [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 25de914a..58c12f05 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -16,7 +16,7 @@ return (static function (): array { return [ 'url_shortener' => [ - 'domain' => [ + 'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http', 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), ], diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index 8e768e39..5de7c53b 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -4,6 +4,7 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +// Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0 return (static function (): array { $webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv(); diff --git a/config/cli-app.php b/config/cli-app.php index a2272852..9287cbaf 100644 --- a/config/cli-app.php +++ b/config/cli-app.php @@ -5,7 +5,7 @@ declare(strict_types=1); use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application as CliApp; -return (static function () { +return (static function (): CliApp { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(CliApp::class); diff --git a/config/constants.php b/config/constants.php index a7bd0bb7..d3d869c3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -19,3 +19,4 @@ const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const MIN_TASK_WORKERS = 4; +const MIGRATIONS_TABLE = 'migrations'; diff --git a/config/container.php b/config/container.php index 56fb345d..074502cd 100644 --- a/config/container.php +++ b/config/container.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Laminas\ServiceManager\ServiceManager; +use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Lock; use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY; @@ -11,6 +12,9 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; +// This is one of the first files loaded. Configure the timezone here +date_default_timezone_set(EnvVars::TIMEZONE()->loadFromEnv(date_default_timezone_get())); + // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name // It needs to be placed here as individual config files will not be loaded once config is cached if (! class_exists(LOCAL_LOCK_FACTORY)) { @@ -18,7 +22,7 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) { } // Build container -return (function () { +return (static function (): ServiceManager { $config = require __DIR__ . '/config.php'; $container = new ServiceManager($config['dependencies']); $container->setService('config', $config); diff --git a/config/entity-manager.php b/config/entity-manager.php index 2b4794f7..6721fec3 100644 --- a/config/entity-manager.php +++ b/config/entity-manager.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Psr\Container\ContainerInterface; -return (static function () { +return (static function (): EntityManagerInterface { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(EntityManager::class); diff --git a/config/test/bootstrap_db_tests.php b/config/test/bootstrap_db_tests.php index 9f14c38d..0237d741 100644 --- a/config/test/bootstrap_db_tests.php +++ b/config/test/bootstrap_db_tests.php @@ -8,5 +8,5 @@ use Psr\Container\ContainerInterface; /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; -$container->get(Helper\TestHelper::class)->createTestDb(); +$container->get(Helper\TestHelper::class)->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']); DbTest\DatabaseTestCase::setEntityManager($container->get('em')); diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 474e4253..3c01294d 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.1.3-fpm-alpine3.15 +FROM php:8.1.5-fpm-alpine3.15 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 @@ -9,7 +9,6 @@ RUN apk update # Install common php extensions RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install iconv RUN docker-php-ext-install calendar RUN apk add --no-cache oniguruma-dev diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index cc004dbe..b7f26c7c 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,9 +1,9 @@ -FROM php:8.1.3-alpine3.15 +FROM php:8.1.5-alpine3.15 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.9.1 +ENV OPENSWOOLE_VERSION 4.11.0 ENV PDO_SQLSRV_VERSION 5.10.0 ENV MS_ODBC_SQL_VERSION 17.5.2.2 @@ -11,7 +11,6 @@ RUN apk update # Install common php extensions RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install iconv RUN docker-php-ext-install calendar RUN apk add --no-cache oniguruma-dev diff --git a/docs/swagger/examples/short-url-not-found.json b/docs/swagger/examples/short-url-not-found.json index 74a5661c..4a58c847 100644 --- a/docs/swagger/examples/short-url-not-found.json +++ b/docs/swagger/examples/short-url-not-found.json @@ -1,7 +1,7 @@ { "value": { - "detail":"No URL found with short code \"abc123\"", - "title":"Short URL not found", + "detail": "No URL found with short code \"abc123\"", + "title": "Short URL not found", "type": "INVALID_SHORTCODE", "status": 404, "shortCode": "abc123" diff --git a/docs/swagger/paths/v2_domains_{domain}_visits.json b/docs/swagger/paths/v2_domains_{domain}_visits.json new file mode 100644 index 00000000..33389f32 --- /dev/null +++ b/docs/swagger/paths/v2_domains_{domain}_visits.json @@ -0,0 +1,172 @@ +{ + "get": { + "operationId": "getDomainVisits", + "tags": [ + "Visits" + ], + "summary": "List visits for domain", + "description": "Get the list of visits on any short URL which belongs to provided domain.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "name": "domain", + "in": "path", + "description": "The domain from which we want to get the visits, or **DEFAULT** keyword for default domain.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "description": "The date (in ISO-8601 format) from which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endDate", + "in": "query", + "description": "The date (in ISO-8601 format) until which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page to display. Defaults to 1", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The amount of items to return on every page. Defaults to all the items", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "excludeBots", + "in": "query", + "description": "Tells if visits from potential bots should be excluded from the result set", + "required": false, + "schema": { + "type": "string", + "enum": ["true"] + } + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "List of visits.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visits": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../definitions/Visit.json" + } + }, + "pagination": { + "$ref": "../definitions/Pagination.json" + } + } + } + } + }, + "example": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "potentialBot": false + }, + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "potentialBot": false + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "potentialBot": true + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 + } + } + } + } + } + }, + "404": { + "description": "The domain does not exist.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + }, + "example": { + "detail": "Domain with authority \"example.com\" could not be found", + "title": "Domain not found", + "type": "DOMAIN_NOT_FOUND", + "status": 404, + "authority": "example.com" + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 3730b527..06f57c41 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -95,6 +95,9 @@ "/rest/v{version}/tags/{tag}/visits": { "$ref": "paths/v2_tags_{tag}_visits.json" }, + "/rest/v{version}/domains/{domain}/visits": { + "$ref": "paths/v2_domains_{domain}_visits.json" + }, "/rest/v{version}/visits/orphan": { "$ref": "paths/v2_visits_orphan.json" }, diff --git a/migrations.php b/migrations.php index 306c1c08..78369f6a 100644 --- a/migrations.php +++ b/migrations.php @@ -2,13 +2,15 @@ declare(strict_types=1); +use const Shlinkio\Shlink\MIGRATIONS_TABLE; + return [ 'migrations_paths' => [ 'ShlinkMigrations' => 'data/migrations', ], 'table_storage' => [ - 'table_name' => 'migrations', + 'table_name' => MIGRATIONS_TABLE, ], 'custom_template' => 'data/migrations_template.txt', diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index a294da9e..415290a3 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Input\InputInterface; @@ -14,6 +15,9 @@ use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; use function Functional\contains; +use function Functional\filter; + +use const Shlinkio\Shlink\MIGRATIONS_TABLE; class CreateDatabaseCommand extends AbstractDatabaseCommand { @@ -35,6 +39,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand { $this ->setName(self::NAME) + ->setHidden() ->setDescription( 'Creates the database needed for shlink to work. It will do nothing if the database already exists', ); @@ -61,7 +66,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private function checkDbExists(): void { - if ($this->regularConn->getDatabasePlatform()->getName() === 'sqlite') { + if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) { return; } @@ -69,7 +74,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand // Otherwise, it will fail to connect and will not be able to create the new database $schemaManager = $this->noDbNameConn->createSchemaManager(); $databases = $schemaManager->listDatabases(); - $shlinkDatabase = $this->regularConn->getDatabase(); + $shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null; if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) { $schemaManager->createDatabase($shlinkDatabase); @@ -79,8 +84,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private function schemaExists(): bool { // If at least one of the shlink tables exist, we will consider the database exists somehow. - // Any inconsistency should be taken care by the migrations + // We exclude the migrations table, in case db:migrate was run first by mistake. + // Any other inconsistency will be taken care by the migrations. $schemaManager = $this->regularConn->createSchemaManager(); - return ! empty($schemaManager->listTableNames()); + return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE)); } } diff --git a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php index 23b39fc6..379e57e0 100644 --- a/module/CLI/src/Command/Db/MigrateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/MigrateDatabaseCommand.php @@ -19,6 +19,7 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand { $this ->setName(self::NAME) + ->setHidden() ->setDescription('Runs database migrations, which will ensure the shlink database is up to date.'); } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 751006bf..ebc9e783 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -209,7 +209,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - (string) $shortUrl->authorApiKey(); + $shortUrl->authorApiKey()?->__toString() ?? ''; } if ($input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index 0c5ef184..ef59d225 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,7 +13,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; - private function __construct(string $message, int $code = 0, ?Throwable $previous = null) + private function __construct(string $message, int $code, ?Throwable $previous) { parent::__construct($message, $code, $previous); } @@ -47,7 +47,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc $e = new self(sprintf( 'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.', $buildEpoch, - )); + ), 0, null); $e->olderDbExists = true; return $e; diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index 67e9d485..22a3bac5 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -66,9 +66,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { $buildTimestamp = $this->resolveBuildTimestamp($meta); $buildDate = Chronos::createFromTimestamp($buildTimestamp); - $now = Chronos::now(); - return $now->gt($buildDate->addDays(35)); + return Chronos::now()->gt($buildDate->addDays(35)); } private function resolveBuildTimestamp(Metadata $meta): int diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index f77f6b79..93e07d4d 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -19,6 +21,8 @@ use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; +use const Shlinkio\Shlink\MIGRATIONS_TABLE; + class CreateDatabaseCommandTest extends TestCase { use CliTestUtilsTrait; @@ -27,7 +31,7 @@ class CreateDatabaseCommandTest extends TestCase private ObjectProphecy $processHelper; private ObjectProphecy $regularConn; private ObjectProphecy $schemaManager; - private ObjectProphecy $databasePlatform; + private ObjectProphecy $driver; public function setUp(): void { @@ -43,11 +47,12 @@ class CreateDatabaseCommandTest extends TestCase $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); $this->schemaManager = $this->prophesize(AbstractSchemaManager::class); - $this->databasePlatform = $this->prophesize(AbstractPlatform::class); $this->regularConn = $this->prophesize(Connection::class); $this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); - $this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal()); + $this->driver = $this->prophesize(Driver::class); + $this->regularConn->getDriver()->willReturn($this->driver->reveal()); + $this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal()); $noDbNameConn = $this->prophesize(Connection::class); $noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); @@ -66,7 +71,7 @@ class CreateDatabaseCommandTest extends TestCase public function successMessageIsPrintedIfDatabaseAlreadyExists(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); @@ -86,11 +91,11 @@ class CreateDatabaseCommandTest extends TestCase public function databaseIsCreatedIfItDoesNotExist(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); - $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']); + $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]); $this->commandTester->execute([]); @@ -100,15 +105,18 @@ class CreateDatabaseCommandTest extends TestCase $listTables->shouldHaveBeenCalledOnce(); } - /** @test */ - public function tablesAreCreatedIfDatabaseIsEmpty(): void + /** + * @test + * @dataProvider provideEmptyDatabase + */ + public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); - $listTables = $this->schemaManager->listTableNames()->willReturn([]); + $listTables = $this->schemaManager->listTableNames()->willReturn($tables); $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ '/usr/local/bin/php', CreateDatabaseCommand::DOCTRINE_SCRIPT, @@ -128,13 +136,19 @@ class CreateDatabaseCommandTest extends TestCase $runCommand->shouldHaveBeenCalledOnce(); } + public function provideEmptyDatabase(): iterable + { + yield 'no tables' => [[]]; + yield 'migrations table' => [[MIGRATIONS_TABLE]]; + } + /** @test */ public function databaseCheckIsSkippedForSqlite(): void { - $this->databasePlatform->getName()->willReturn('sqlite'); + $this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal()); $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/Util/GeolocationDbUpdaterTest.php index 83340fc5..c5e3bdb4 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/Util/GeolocationDbUpdaterTest.php @@ -30,6 +30,7 @@ class GeolocationDbUpdaterTest extends TestCase private ObjectProphecy $dbUpdater; private ObjectProphecy $geoLiteDbReader; private TrackingOptions $trackingOptions; + private ObjectProphecy $lock; public function setUp(): void { @@ -38,11 +39,11 @@ class GeolocationDbUpdaterTest extends TestCase $this->trackingOptions = new TrackingOptions(); $locker = $this->prophesize(Lock\LockFactory::class); - $lock = $this->prophesize(Lock\LockInterface::class); - $lock->acquire(true)->willReturn(true); - $lock->release()->will(function (): void { + $this->lock = $this->prophesize(Lock\LockInterface::class); + $this->lock->acquire(true)->willReturn(true); + $this->lock->release()->will(function (): void { }); - $locker->createLock(Argument::type('string'))->willReturn($lock->reveal()); + $locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); $this->geolocationDbUpdater = new GeolocationDbUpdater( $this->dbUpdater->reveal(), @@ -75,6 +76,8 @@ class GeolocationDbUpdaterTest extends TestCase $fileExists->shouldHaveBeenCalledOnce(); $getMeta->shouldNotHaveBeenCalled(); $download->shouldHaveBeenCalledOnce(); + $this->lock->acquire(true)->shouldHaveBeenCalledOnce(); + $this->lock->release()->shouldHaveBeenCalledOnce(); } /** diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 567fde47..db9a11b9 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -12,6 +12,7 @@ use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; +use function date_default_timezone_get; use function Functional\reduce_left; use function is_array; use function print_r; @@ -32,7 +33,7 @@ function generateRandomShortCode(int $length): string function parseDateFromQuery(array $query, string $dateName): ?Chronos { - return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]); + return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); } function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange @@ -43,29 +44,15 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } -function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos +function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos { - if ($date === null || $date instanceof Chronos) { - return $date; - } + $parsedDate = match (true) { + $date === null || $date instanceof Chronos => $date, + $date instanceof DateTimeInterface => Chronos::instance($date), + default => Chronos::parse($date), + }; - if ($date instanceof DateTimeInterface) { - return Chronos::instance($date); - } - - return Chronos::parse($date); -} - -function determineTableName(string $tableName, array $emConfig = []): string -{ - $schema = $emConfig['connection']['schema'] ?? null; -// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO - - if ($schema === null) { - return $tableName; - } - - return sprintf('%s.%s', $schema, $tableName); + return $parsedDate?->setTimezone(date_default_timezone_get()); } function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int @@ -108,6 +95,18 @@ function isCrawler(string $userAgent): bool return $detector->isCrawler($userAgent); } +function determineTableName(string $tableName, array $emConfig = []): string +{ + $schema = $emConfig['connection']['schema'] ?? null; +// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO + + if ($schema === null) { + return $tableName; + } + + return sprintf('%s.%s', $schema, $tableName); +} + function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder { return match ($emConfig['connection']['driver'] ?? null) { diff --git a/module/Core/src/Config/BasePathPrefixer.php b/module/Core/src/Config/BasePathPrefixer.php index 1ad4e23b..4a306287 100644 --- a/module/Core/src/Config/BasePathPrefixer.php +++ b/module/Core/src/Config/BasePathPrefixer.php @@ -13,7 +13,6 @@ class BasePathPrefixer public function __invoke(array $config): array { $basePath = $config['router']['base_path'] ?? ''; - $config['url_shortener']['domain']['hostname'] .= $basePath; foreach (self::ELEMENTS_WITH_PATH as $configKey) { $config[$configKey] = $this->prefixPathsWithBasePath($configKey, $config, $basePath); diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index e3771e4c..112b7599 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -12,7 +12,7 @@ use function array_values; use function Functional\contains; use function Shlinkio\Shlink\Config\env; -// TODO Convert to enum +// TODO Convert to enum after dropping PHP 8.0 support /** * @method static EnvVars DELETE_SHORT_URL_THRESHOLD() @@ -62,6 +62,7 @@ use function Shlinkio\Shlink\Config\env; * @method static EnvVars DEFAULT_DOMAIN() * @method static EnvVars AUTO_RESOLVE_TITLES() * @method static EnvVars REDIRECT_APPEND_EXTRA_PATH() + * @method static EnvVars TIMEZONE() * @method static EnvVars VISITS_WEBHOOKS() * @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS() */ @@ -114,7 +115,10 @@ final class EnvVars public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; + public const TIMEZONE = 'TIMEZONE'; + /** @deprecated */ public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; + /** @deprecated */ public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; /** diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 4de3ea36..0a99b3c6 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; @@ -40,8 +41,25 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain { - $qb = $this->createQueryBuilder('d'); - $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + $qb = $this->createDomainQueryBuilder($authority, $apiKey); + $qb->select('d'); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function domainExists(string $authority, ?ApiKey $apiKey = null): bool + { + $qb = $this->createDomainQueryBuilder($authority, $apiKey); + $qb->select('COUNT(d.id)'); + + return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; + } + + private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Domain::class, 'd') + ->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') ->where($qb->expr()->eq('d.authority', ':authority')) ->setParameter('authority', $authority) ->setMaxResults(1); @@ -51,7 +69,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe $this->applySpecification($qb, $spec, $alias); } - return $qb->getQuery()->getOneOrNullResult(); + return $qb; } private function determineExtraSpecs(?ApiKey $apiKey): iterable diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 69e74e5b..d5f880bd 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -17,4 +17,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio public function findDomains(?ApiKey $apiKey = null): array; public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + + public function domainExists(string $authority, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index b5c2e501..73ff9266 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -21,6 +21,7 @@ use Throwable; use function Functional\map; +/** @deprecated */ class NotifyVisitToWebHooks { public function __construct( diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index fe4f24df..6b33c586 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -13,8 +13,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Importer\Params\ImportParams; use Shlinkio\Shlink\Importer\Sources\ImportSources; +use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\Console\Style\StyleInterface; +use Throwable; use function sprintf; @@ -32,32 +35,36 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface } /** - * @param iterable|ImportedShlinkUrl[] $shlinkUrls + * @param iterable $shlinkUrls */ - public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void + public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void { - $importShortCodes = $params['import_short_codes']; - $source = $params['source']; + $importShortCodes = $params->importShortCodes(); + $source = $params->source(); $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100); /** @var ImportedShlinkUrl $importedUrl */ foreach ($iterable as $importedUrl) { - $skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool { - $action = $io->choice(sprintf( - 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate ' - . 'a new one or skip it?', - $importedUrl->longUrl(), - $importedUrl->shortCode(), - ), ['Generate new short-code', 'Skip'], 1); - - return $action === 'Skip'; - }; + $skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf( + 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate ' + . 'a new one or skip it?', + $importedUrl->longUrl(), + $importedUrl->shortCode(), + ), ['Generate new short-code', 'Skip'], 1) === 'Skip'; $longUrl = $importedUrl->longUrl(); try { $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); } catch (NonUniqueSlugException) { $io->text(sprintf('%s: Error', $longUrl)); + continue; + } catch (Throwable $e) { + $io->text(sprintf('%s: Skipped. Reason: %s.', $longUrl, $e->getMessage())); + + if ($io instanceof OutputStyle && $io->isVerbose()) { + $io->text($e->__toString()); + } + continue; } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index a925c5d5..e1680517 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -29,7 +29,7 @@ final class ShortUrlImporting } /** - * @param iterable|ImportedShlinkVisit[] $visits + * @param iterable $visits */ public function importVisits(iterable $visits, EntityManagerInterface $em): string { diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index d27d1fe6..325ee339 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use function array_key_exists; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; final class ShortUrlEdit implements TitleResolutionModelInterface { @@ -69,8 +69,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface $this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 86f2c9d1..f43f929d 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -68,8 +68,8 @@ final class ShortUrlMeta implements TitleResolutionModelInterface } $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index 9abfd10f..95cf4df6 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use function Shlinkio\Shlink\Common\buildDateRange; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; final class ShortUrlsParams { @@ -61,8 +61,8 @@ final class ShortUrlsParams $this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM); $this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS); $this->dateRange = buildDateRange( - parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), - parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), + normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), + normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), ); $this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)); $this->itemsPerPage = (int) ( diff --git a/module/Core/src/Options/WebhookOptions.php b/module/Core/src/Options/WebhookOptions.php index 6eb07692..7196fd0c 100644 --- a/module/Core/src/Options/WebhookOptions.php +++ b/module/Core/src/Options/WebhookOptions.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +/** @deprecated */ class WebhookOptions extends AbstractOptions { protected $__strictMode__ = false; // phpcs:ignore diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index b43d676d..51a0c333 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -154,6 +154,47 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $qb; } + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + } + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + $qb->select('COUNT(v.id)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFiltering $filtering): QueryBuilder + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later. + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Visit::class, 'v') + ->join('v.shortUrl', 's'); + + if ($domain === 'DEFAULT') { + $qb->where($qb->expr()->isNull('s.domain')); + } else { + $qb->join('s.domain', 'd') + ->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain))); + } + + if ($filtering->excludeBots()) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } + + $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); + + return $qb; + } + public function findOrphanVisits(VisitsListFiltering $filtering): array { $qb = $this->createAllVisitsQueryBuilder($filtering); diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 3d480c01..837dea1b 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int; + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array; + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int; + /** * @return Visit[] */ diff --git a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php new file mode 100644 index 00000000..508a7b36 --- /dev/null +++ b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php @@ -0,0 +1,49 @@ +visitRepository->countVisitsByDomain( + $this->domain, + new VisitsCountFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->apiKey, + ), + ); + } + + public function getSlice(int $offset, int $length): iterable + { + return $this->visitRepository->findVisitsByDomain( + $this->domain, + new VisitsListFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->apiKey, + $length, + $offset, + ), + ); + } +} diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 914a9c5b..007ed334 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -19,6 +22,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; @@ -85,6 +89,24 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); } + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + { + /** @var DomainRepository $domainRepo */ + $domainRepo = $this->em->getRepository(Domain::class); + if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) { + throw DomainNotFoundException::fromAuthority($domain); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + + return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); + } + /** * @return Visit[]|Paginator */ diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 3616b531..b32fc99d 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -33,6 +34,12 @@ interface VisitsStatsHelperInterface */ public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + /** * @return Visit[]|Paginator */ diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 3f69e7d9..d1b3bbeb 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -55,6 +55,10 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com')); + self::assertTrue($this->repo->domainExists('bar.com')); + self::assertTrue($this->repo->domainExists('detached-with-redirects.com')); + self::assertFalse($this->repo->domainExists('does-not-exist.com')); + self::assertTrue($this->repo->domainExists('detached.com')); } /** @test */ @@ -115,6 +119,12 @@ class DomainRepositoryTest extends DatabaseTestCase $this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey), ); self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey)); + + self::assertTrue($this->repo->domainExists('foo.com', $authorApiKey)); + self::assertFalse($this->repo->domainExists('bar.com', $authorApiKey)); + self::assertTrue($this->repo->domainExists('bar.com', $barDomainApiKey)); + self::assertTrue($this->repo->domainExists('detached-with-redirects.com', $detachedWithRedirectsApiKey)); + self::assertFalse($this->repo->domainExists('foo.com', $detachedWithRedirectsApiKey)); } private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index c23bd8aa..b16c3382 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -52,7 +52,7 @@ class VisitRepositoryTest extends DatabaseTestCase { $shortUrl = ShortUrl::createEmpty(); $this->getEntityManager()->persist($shortUrl); - $countIterable = function (iterable $results): int { + $countIterable = static function (iterable $results): int { $resultsCount = 0; foreach ($results as $value) { $resultsCount++; @@ -256,6 +256,54 @@ class VisitRepositoryTest extends DatabaseTestCase ))); } + /** @test */ + public function findVisitsByDomainReturnsProperData(): void + { + $this->createShortUrlsAndVisits('doma.in'); + $this->getEntityManager()->flush(); + + self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); + self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering())); + self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); + self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), + ))); + self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), + ))); + } + + /** @test */ + public function countVisitsByDomainReturnsProperData(): void + { + $this->createShortUrlsAndVisits('doma.in'); + $this->getEntityManager()->flush(); + + self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); + self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering())); + self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); + self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), + ))); + self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), + ))); + } + /** @test */ public function countVisitsReturnsExpectedResultBasedOnApiKey(): void { diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index f01b9195..36b038c8 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -24,42 +24,16 @@ class BasePathPrefixerTest extends TestCase array $originalConfig, array $expectedRoutes, array $expectedMiddlewares, - string $expectedHostname, ): void { - [ - 'routes' => $routes, - 'middleware_pipeline' => $middlewares, - 'url_shortener' => $urlShortener, - ] = ($this->prefixer)($originalConfig); + ['routes' => $routes, 'middleware_pipeline' => $middlewares] = ($this->prefixer)($originalConfig); self::assertEquals($expectedRoutes, $routes); self::assertEquals($expectedMiddlewares, $middlewares); - self::assertEquals([ - 'domain' => [ - 'hostname' => $expectedHostname, - ], - ], $urlShortener); } public function provideConfig(): iterable { - $urlShortener = [ - 'domain' => [ - 'hostname' => null, - ], - ]; - - yield 'without anything' => [['url_shortener' => $urlShortener], [], [], '']; - yield 'with empty options' => [ - [ - 'routes' => [], - 'middleware_pipeline' => [], - 'url_shortener' => $urlShortener, - ], - [], - [], - '', - ]; + yield 'with empty options' => [['routes' => []], [], []]; yield 'with non-empty options' => [ [ 'routes' => [ @@ -70,11 +44,6 @@ class BasePathPrefixerTest extends TestCase ['with' => 'no_path'], ['path' => '/rest', 'middleware' => []], ], - 'url_shortener' => [ - 'domain' => [ - 'hostname' => 'doma.in', - ], - ], 'router' => ['base_path' => '/foo/bar'], ], [ @@ -85,7 +54,6 @@ class BasePathPrefixerTest extends TestCase ['with' => 'no_path'], ['path' => '/foo/bar/rest', 'middleware' => []], ], - 'doma.in/foo/bar', ]; } } diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index a7ccbcee..51a7a088 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -77,6 +77,7 @@ class EnvVarsTest extends TestCase EnvVars::DEFAULT_DOMAIN, EnvVars::AUTO_RESOLVE_TITLES, EnvVars::REDIRECT_APPEND_EXTRA_PATH, + EnvVars::TIMEZONE, EnvVars::VISITS_WEBHOOKS, EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS, ], $list); diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 70662bb1..341b32bc 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; @@ -20,6 +21,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; +use Shlinkio\Shlink\Importer\Params\ImportParams; use Shlinkio\Shlink\Importer\Sources\ImportSources; use Symfony\Component\Console\Style\StyleInterface; @@ -32,8 +34,6 @@ class ImportedLinksProcessorTest extends TestCase { use ProphecyTrait; - private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY]; - private ImportedLinksProcessor $processor; private ObjectProphecy $em; private ObjectProphecy $shortCodeHelper; @@ -74,7 +74,7 @@ class ImportedLinksProcessorTest extends TestCase $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls); $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); @@ -82,6 +82,37 @@ class ImportedLinksProcessorTest extends TestCase $this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls); } + /** @test */ + public function newUrlsWithErrorsAreSkipped(): void + { + $urls = [ + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', 'foo'), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null), + ]; + + $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $persist = $this->em->persist(Argument::type(ShortUrl::class))->will(function (array $args): void { + /** @var ShortUrl $shortUrl */ + [$shortUrl] = $args; + + if ($shortUrl->getShortCode() === 'baz') { + throw new RuntimeException('Whatever error'); + } + }); + + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); + + $importedUrlExists->shouldHaveBeenCalledTimes(3); + $ensureUniqueness->shouldHaveBeenCalledTimes(3); + $persist->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); + $this->io->text( + Argument::containingString('Skipped. Reason: Whatever error'), + )->shouldHaveBeenCalledOnce(); + } + /** @test */ public function alreadyImportedUrlsAreSkipped(): void { @@ -104,7 +135,7 @@ class ImportedLinksProcessorTest extends TestCase $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $ensureUniqueness->shouldHaveBeenCalledTimes(2); @@ -141,7 +172,7 @@ class ImportedLinksProcessorTest extends TestCase }); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); @@ -167,7 +198,7 @@ class ImportedLinksProcessorTest extends TestCase $persistUrl = $this->em->persist(Argument::type(ShortUrl::class)); $persistVisits = $this->em->persist(Argument::type(Visit::class)); - $this->processor->process($this->io->reveal(), [$importedUrl], self::PARAMS); + $this->processor->process($this->io->reveal(), [$importedUrl], $this->buildParams()); $findExisting->shouldHaveBeenCalledOnce(); $ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); @@ -214,4 +245,12 @@ class ImportedLinksProcessorTest extends TestCase ])), ]; } + + private function buildParams(): ImportParams + { + return ImportParams::fromSourceAndCallableMap( + ImportSources::BITLY, + ['import_short_codes' => static fn () => true], + ); + } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 731697e6..42c821bb 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -10,9 +10,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -158,6 +161,69 @@ class VisitsStatsHelperTest extends TestCase $getRepo->shouldHaveBeenCalledOnce(); } + /** @test */ + public function throwsExceptionWhenRequestingVisitsForInvalidDomain(): void + { + $domain = 'foo.com'; + $apiKey = ApiKey::create(); + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(false); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $this->expectException(DomainNotFoundException::class); + $domainExists->shouldBeCalledOnce(); + $getRepo->shouldBeCalledOnce(); + + $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + { + $domain = 'foo.com'; + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(true); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByDomain($domain, Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByDomain($domain, Argument::type(VisitsCountFiltering::class))->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $domainExists->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + { + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists(Argument::cetera()); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByDomain('DEFAULT', Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByDomain('DEFAULT', Argument::type(VisitsCountFiltering::class))->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $domainExists->shouldNotHaveBeenCalled(); + $getRepo->shouldHaveBeenCalledOnce(); + } + /** @test */ public function orphanVisitsAreReturnedAsExpected(): void { diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 5f0d5c05..34be71f4 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Rest; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; +use Mezzio\ProblemDetails\ProblemDetailsResponseFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; +use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options; @@ -32,6 +34,7 @@ return [ Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class, @@ -49,6 +52,7 @@ return [ Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, + Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class, ], ], @@ -70,6 +74,10 @@ return [ ], Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\Visit\DomainVisitsAction::class => [ + Visit\VisitsStatsHelper::class, + 'config.url_shortener.domain.hostname', + ], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\OrphanVisitsAction::class => [ Visit\VisitsStatsHelper::class, @@ -90,6 +98,10 @@ return [ 'config.url_shortener.default_short_codes_length', ], Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class], + Middleware\Mercure\NotConfiguredMercureErrorHandler::class => [ + ProblemDetailsResponseFactory::class, + LoggerInterface::class, + ], ], ]; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 16f83149..f318664f 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -4,49 +4,54 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; -$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; -$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; -$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; +use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; -return [ +return (static function (): array { + $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; + $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; + $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; - 'routes' => [ - Action\HealthAction::getRouteDef(), + return [ - // Short URLs - Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $dropDomainMiddleware, - $overrideDomainMiddleware, - Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, - ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $overrideDomainMiddleware, - ]), - Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ListShortUrlsAction::getRouteDef(), + 'routes' => [ + Action\HealthAction::getRouteDef(), - // Visits - Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), - Action\Visit\TagVisitsAction::getRouteDef(), - Action\Visit\GlobalVisitsAction::getRouteDef(), - Action\Visit\OrphanVisitsAction::getRouteDef(), - Action\Visit\NonOrphanVisitsAction::getRouteDef(), + // Short URLs + Action\ShortUrl\CreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $dropDomainMiddleware, + $overrideDomainMiddleware, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, + ]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), + Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ListShortUrlsAction::getRouteDef(), - // Tags - Action\Tag\ListTagsAction::getRouteDef(), - Action\Tag\TagsStatsAction::getRouteDef(), - Action\Tag\DeleteTagsAction::getRouteDef(), - Action\Tag\UpdateTagAction::getRouteDef(), + // Visits + Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\TagVisitsAction::getRouteDef(), + Action\Visit\DomainVisitsAction::getRouteDef(), + Action\Visit\GlobalVisitsAction::getRouteDef(), + Action\Visit\OrphanVisitsAction::getRouteDef(), + Action\Visit\NonOrphanVisitsAction::getRouteDef(), - // Domains - Action\Domain\ListDomainsAction::getRouteDef(), - Action\Domain\DomainRedirectsAction::getRouteDef(), + // Tags + Action\Tag\ListTagsAction::getRouteDef(), + Action\Tag\TagsStatsAction::getRouteDef(), + Action\Tag\DeleteTagsAction::getRouteDef(), + Action\Tag\UpdateTagAction::getRouteDef(), - Action\MercureInfoAction::getRouteDef(), - ], + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), -]; + Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), + ], + + ]; +})(); diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php index d6710357..1454cbbc 100644 --- a/module/Rest/src/Action/MercureInfoAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -10,7 +10,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Exception\MercureException; -use Throwable; use function sprintf; @@ -32,12 +31,7 @@ class MercureInfoAction extends AbstractRestAction $days = $this->mercureConfig['jwt_days_duration'] ?? 1; $expiresAt = Chronos::now()->addDays($days); - - try { - $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); - } catch (Throwable $e) { - throw MercureException::mercureNotConfigured($e); - } + $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); return new JsonResponse([ 'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl), diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php new file mode 100644 index 00000000..b68d971f --- /dev/null +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -0,0 +1,48 @@ +resolveDomainParam($request); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); + } + + private function resolveDomainParam(Request $request): string + { + $domainParam = $request->getAttribute('domain', ''); + if ($domainParam === $this->defaultDomain) { + return 'DEFAULT'; + } + + return $domainParam; + } +} diff --git a/module/Rest/src/Exception/MercureException.php b/module/Rest/src/Exception/MercureException.php index 6c318e93..9435cb54 100644 --- a/module/Rest/src/Exception/MercureException.php +++ b/module/Rest/src/Exception/MercureException.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -use Throwable; class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface { @@ -16,9 +15,9 @@ class MercureException extends RuntimeException implements ProblemDetailsExcepti private const TITLE = 'Mercure integration not configured'; private const TYPE = 'MERCURE_NOT_CONFIGURED'; - public static function mercureNotConfigured(?Throwable $prev = null): self + public static function mercureNotConfigured(): self { - $e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev); + $e = new self('This Shlink instance is not integrated with a mercure hub.'); $e->detail = $e->getMessage(); $e->title = self::TITLE; diff --git a/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php b/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php new file mode 100644 index 00000000..32a714b6 --- /dev/null +++ b/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php @@ -0,0 +1,34 @@ +handle($request); + } catch (MercureException $e) { + // Throwing this kind of exception makes a big error trace to be logged, for anyone who has decided to not + // use mercure. + // It happens every time the shlink-web-client is opened, so this mitigates the problem by just logging a + // simple warning, and casting the exception to a response on the fly. + $this->logger->warning($e->getMessage()); + return $this->respFactory->createResponseFromThrowable($request, $e); + } + } +} diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php new file mode 100644 index 00000000..b6e29a12 --- /dev/null +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -0,0 +1,68 @@ +callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [ + RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertArrayHasKey('visits', $payload); + self::assertArrayHasKey('data', $payload['visits']); + self::assertCount($expectedVisitsAmount, $payload['visits']['data']); + } + + public function provideDomains(): iterable + { + yield 'example.com with admin API key' => ['valid_api_key', 'example.com', false, 0]; + yield 'DEFAULT with admin API key' => ['valid_api_key', 'DEFAULT', false, 7]; + yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', 'DEFAULT', true, 6]; + yield 'DEFAULT with domain API key' => ['domain_api_key', 'DEFAULT', false, 0]; + yield 'DEFAULT with author API key' => ['author_api_key', 'DEFAULT', false, 5]; + yield 'DEFAULT with author API key and no bots' => ['author_api_key', 'DEFAULT', true, 4]; + } + + /** + * @test + * @dataProvider provideApiKeysAndTags + */ + public function notFoundErrorIsReturnedForInvalidTags(string $apiKey, string $domain): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']); + self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']); + self::assertEquals('Domain not found', $payload['title']); + self::assertEquals($domain, $payload['authority']); + } + + public function provideApiKeysAndTags(): iterable + { + yield 'admin API key with invalid domain' => ['valid_api_key', 'invalid_domain.com']; + yield 'domain API key with not-owned valid domain' => ['domain_api_key', 'this_domain_is_detached.com']; + yield 'author API key with valid domain not used in URLs' => ['author_api_key', 'this_domain_is_detached.com']; + } +} diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index eca4177d..33083c79 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use RuntimeException; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Action\MercureInfoAction; use Shlinkio\Shlink\Rest\Exception\MercureException; @@ -49,24 +48,6 @@ class MercureInfoActionTest extends TestCase yield 'host is null' => [['public_hub_url' => null]]; } - /** - * @test - * @dataProvider provideValidConfigs - */ - public function throwsExceptionWhenBuildingTokenFails(array $mercureConfig): void - { - $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willThrow( - new RuntimeException('Error'), - ); - - $action = new MercureInfoAction($this->provider->reveal(), $mercureConfig); - - $this->expectException(MercureException::class); - $buildToken->shouldBeCalledOnce(); - - $action->handle(ServerRequestFactory::fromGlobals()); - } - public function provideValidConfigs(): iterable { yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']]; diff --git a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php new file mode 100644 index 00000000..84acb1f1 --- /dev/null +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -0,0 +1,60 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->action = new DomainVisitsAction($this->visitsHelper->reveal(), 'the_default.com'); + } + + /** + * @test + * @dataProvider provideDomainAuthorities + */ + public function providingCorrectDomainReturnsVisits(string $providedDomain, string $expectedDomain): void + { + $apiKey = ApiKey::create(); + $getVisits = $this->visitsHelper->visitsForDomain( + $expectedDomain, + Argument::type(VisitsParams::class), + $apiKey, + )->willReturn(new Paginator(new ArrayAdapter([]))); + + $response = $this->action->handle( + ServerRequestFactory::fromGlobals()->withAttribute('domain', $providedDomain) + ->withAttribute(ApiKey::class, $apiKey), + ); + + self::assertEquals(200, $response->getStatusCode()); + $getVisits->shouldHaveBeenCalledOnce(); + } + + public function provideDomainAuthorities(): iterable + { + yield 'no default domain' => ['foo.com', 'foo.com']; + yield 'default domain' => ['the_default.com', 'DEFAULT']; + yield 'DEFAULT keyword' => ['DEFAULT', 'DEFAULT']; + } +} diff --git a/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php new file mode 100644 index 00000000..138c01f0 --- /dev/null +++ b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php @@ -0,0 +1,62 @@ +respFactory = $this->prophesize(ProblemDetailsResponseFactory::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->middleware = new NotConfiguredMercureErrorHandler($this->respFactory->reveal(), $this->logger->reveal()); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + } + + /** @test */ + public function requestHandlerIsInvokedWhenNotErrorOccurs(): void + { + $req = ServerRequestFactory::fromGlobals(); + $handle = $this->handler->handle($req)->willReturn(new Response()); + + $this->middleware->process($req, $this->handler->reveal()); + + $handle->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->respFactory->createResponseFromThrowable(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function exceptionIsParsedToResponse(): void + { + $req = ServerRequestFactory::fromGlobals(); + $handle = $this->handler->handle($req)->willThrow(MercureException::mercureNotConfigured()); + $createResp = $this->respFactory->createResponseFromThrowable(Argument::cetera())->willReturn(new Response()); + + $this->middleware->process($req, $this->handler->reveal()); + + $handle->shouldHaveBeenCalledOnce(); + $createResp->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldHaveBeenCalledOnce(); + } +}