diff --git a/.dockerignore b/.dockerignore index f9102acb..9fb114c1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,7 +5,7 @@ data/log/* data/locks/* data/proxies/* data/migrations_template.txt -data/GeoLite2-City.* +data/GeoLite2-City* data/database.sqlite data/shlink-tests.db CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b76317..f1aefb80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -21,7 +21,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer cs @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -39,7 +39,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer stan @@ -57,7 +57,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -83,7 +83,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -111,7 +111,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:mysql @@ -131,7 +131,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:maria @@ -151,7 +151,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer test:db:postgres @@ -173,7 +173,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0 + extensions: swoole-4.6.7, pdo_sqlsrv-5.9.0 coverage: none - run: composer install --no-interaction --prefer-dist - name: Create test database @@ -195,7 +195,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist @@ -217,6 +217,7 @@ jobs: strategy: matrix: php-version: ['7.4', '8.0'] + test-group: ['unit', 'db'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -225,14 +226,14 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: swoole-4.6.3 + extensions: swoole-4.6.7 coverage: pcov ini-values: pcov.directory=module - run: composer install --no-interaction --prefer-dist - uses: actions/download-artifact@v2 with: path: build - - run: composer infect:ci + - run: composer infect:ci:${{ matrix.test-group }} upload-coverage: needs: @@ -242,7 +243,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - php-version: ['7.4'] + php-version: ['8.0'] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 18c174c8..26ee4ac0 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: swoole-4.6.3 + extensions: swoole-4.6.7 - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} - if: ${{ matrix.swoole == 'no' }} diff --git a/.gitignore b/.gitignore index 03b2790e..32942a29 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ composer.phar vendor/ data/database.sqlite data/shlink-tests.db -data/GeoLite2-City.mmdb -data/GeoLite2-City.mmdb.* +data/GeoLite2-City.* docs/swagger-ui* docs/mercure.html docker-compose.override.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb7d5f2..b7df04c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ 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). +## [2.7.0] - 2021-05-23 +### Added +* [#1044](https://github.com/shlinkio/shlink/issues/1044) Added ability to set names on API keys, which helps to identify them when the list grows. +* [#819](https://github.com/shlinkio/shlink/issues/819) Visits are now always located in real time, even when not using swoole. + + The only side effect is that a GeoLite2 db file is now installed when the docker image starts or during shlink installation or update. + + Also, when using swoole, the file is now updated **after** tracking a visit, which means it will not apply until the next one. + +* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line. +* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API. +* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it. + + In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer: + + * `disable_tracking`: If true, visits will not be tracked at all. + * `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved. + * `disable_referrer_tracking`: If true, the referrer will not be tracked. + * `disable_ua_tracking`: If true, the user agent will not be tracked. + +* [#955](https://github.com/shlinkio/shlink/issues/955) Added new option to set short URLs as crawlable, making them be listed in the robots.txt as Allowed. +* [#900](https://github.com/shlinkio/shlink/issues/900) Shlink now tries to detect if the visit is coming from a potential bot or crawler, and allows to exclude those visits from visits lists if desired. + +### Changed +* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. +* [#1039](https://github.com/shlinkio/shlink/issues/1039) Updated to `endroid/qr-code` 4.0. +* [#1008](https://github.com/shlinkio/shlink/issues/1008) Ensured all logs are sent to the filesystem while running API tests, which helps debugging the reason for tests to fail. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1041](https://github.com/shlinkio/shlink/issues/1041) Ensured the default value for the version while building the docker image is `latest`. +* [#1067](https://github.com/shlinkio/shlink/issues/1067) Fixed exception when persisting multiple short URLs in one batch which include the same new tags/domains. This can potentially happen when importing URLs. + + ## [2.6.2] - 2021-03-12 ### Added * *Nothing* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 234bab5e..837f7593 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,11 +96,13 @@ In order to ensure stability and no regressions are introduced while developing The project provides some tooling to run them against any of the supported database engines. -* **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API. +* **API tests**: These are E2E tests that spin up an instance of the app with swoole, and test it from the outside by interacting with the REST API. These are the best tests to catch regressions, and to verify everything behaves as expected. - They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution. + They use Postgres as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution. + + Since the app instance is run on a process different from the one running the tests, when a test fails it might not be obvious why. To help debugging that, the app will dump all its logs inside `data/log/api-tests`, where you will find the `shlink.log` and `access.log` files. * **CLI tests**: *TBD. Once included, its purpose will be the same as API tests, but running through the command line* @@ -118,7 +120,7 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, For example, `test:db:postgres`. -* Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used. +* Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. * Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). * 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. diff --git a/Dockerfile b/Dockerfile index fd703ebc..c07adc28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ -FROM php:8.0.2-alpine3.13 as base +FROM php:8.0.6-alpine3.13 as base -ARG SHLINK_VERSION=2.5.2 +ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.6.3 +ENV SWOOLE_VERSION 4.6.7 ENV PDO_SQLSRV_VERSION 5.9.0 +ENV MS_ODBC_SQL_VERSION 17.5.2.1 ENV LC_ALL "C" WORKDIR /etc/shlink @@ -30,13 +31,13 @@ RUN \ # Install sqlsrv driver RUN if [ $(uname -m) == "x86_64" ]; then \ - wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ + wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk ; \ + rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \ fi # Install swoole @@ -69,6 +70,8 @@ EXPOSE 8080 # Expose params config dir, since the user is expected to provide custom config from there VOLUME /etc/shlink/config/params +# Expose data dir to allow persistent runtime data and SQLite db +VOLUME /etc/shlink/data # Copy config specific for the image COPY docker/docker-entrypoint.sh docker-entrypoint.sh diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 07b36881..dbd87a84 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -3,6 +3,8 @@ export APP_ENV=test export DB_DRIVER=postgres export TEST_ENV=api +rm -rf data/log/api-tests + # Try to stop server just in case it hanged in last execution vendor/bin/laminas mezzio:swoole:stop diff --git a/composer.json b/composer.json index c2c8dc45..7a84c886 100644 --- a/composer.json +++ b/composer.json @@ -19,12 +19,13 @@ "cakephp/chronos": "^2.0", "cocur/slugify": "^4.0", "doctrine/cache": "^1.9", - "doctrine/migrations": "^3.0.2", - "doctrine/orm": "2.8.1 || ^2.8.3", - "endroid/qr-code": "3.x-dev#0f1613a as 3.10", + "doctrine/migrations": "^3.1.1", + "doctrine/orm": "^2.8.4", + "endroid/qr-code": "^4.0", "geoip2/geoip2": "^2.9", "guzzlehttp/guzzle": "^7.0", - "happyr/doctrine-specification": "2.x-dev#cb116d3 as 2.0", + "happyr/doctrine-specification": "^2.0", + "jaybizzle/crawler-detect": "^1.2", "laminas/laminas-config": "^3.3", "laminas/laminas-config-aggregator": "^1.1", "laminas/laminas-diactoros": "^2.1.3", @@ -33,7 +34,7 @@ "laminas/laminas-stdlib": "^3.2", "lcobucci/jwt": "^4.0", "league/uri": "^6.2", - "lstrojny/functional-php": "^1.15", + "lstrojny/functional-php": "^1.17", "mezzio/mezzio": "^3.3", "mezzio/mezzio-fastroute": "^3.1", "mezzio/mezzio-problem-details": "^1.3", @@ -46,16 +47,16 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.7", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "^3.5", + "shlinkio/shlink-common": "^3.7", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^2.1", - "shlinkio/shlink-importer": "^2.2", - "shlinkio/shlink-installer": "^5.4", + "shlinkio/shlink-importer": "^2.3", + "shlinkio/shlink-installer": "^6.0", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", "symfony/filesystem": "^5.1", "symfony/lock": "^5.1", - "symfony/mercure": "^0.4.1", + "symfony/mercure": "^0.5.1", "symfony/process": "^5.1", "symfony/string": "^5.1" }, @@ -124,6 +125,7 @@ ], "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", + "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html", "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", @@ -132,7 +134,6 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html", "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index f64f9cff..0b7ec937 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -7,7 +7,6 @@ return [ 'app_options' => [ 'name' => 'Shlink', 'version' => '%SHLINK_VERSION%', - 'disable_track_param' => null, ], ]; diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 639df7ec..c3d2ab83 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Common; -use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; return [ diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index 1faed328..f3cca338 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -2,14 +2,6 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; - -// When running tests, any mysql-specific option can interfere with other drivers -$driverOptions = env('APP_ENV') === 'test' ? [] : [ - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', - PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, -]; - return [ 'entity_manager' => [ @@ -18,7 +10,6 @@ return [ 'password' => 'root', 'driver' => 'pdo_mysql', 'host' => 'shlink_db', - 'driverOptions' => $driverOptions, ], ], diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index d18f31f4..0a72c6fa 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -2,7 +2,10 @@ declare(strict_types=1); +namespace Shlinkio\Shlink\CLI; + use Shlinkio\Shlink\Installer\Config\Option; +use Shlinkio\Shlink\Installer\Util\InstallationCommand; return [ @@ -24,7 +27,6 @@ return [ Option\Redirect\BaseUrlRedirectConfigOption::class, Option\Redirect\InvalidShortUrlRedirectConfigOption::class, Option\Redirect\Regular404RedirectConfigOption::class, - Option\DisableTrackParamConfigOption::class, Option\Visit\CheckVisitsThresholdConfigOption::class, Option\Visit\VisitsThresholdConfigOption::class, Option\BasePathConfigOption::class, @@ -37,19 +39,27 @@ return [ Option\Mercure\MercureInternalUrlConfigOption::class, Option\Mercure\MercureJwtSecretConfigOption::class, Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class, - Option\UrlShortener\IpAnonymizationConfigOption::class, Option\UrlShortener\RedirectStatusCodeConfigOption::class, Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class, - Option\UrlShortener\OrphanVisitsTrackingConfigOption::class, + Option\Tracking\IpAnonymizationConfigOption::class, + Option\Tracking\OrphanVisitsTrackingConfigOption::class, + Option\Tracking\DisableTrackParamConfigOption::class, + Option\Tracking\DisableTrackingConfigOption::class, + Option\Tracking\DisableIpTrackingConfigOption::class, + Option\Tracking\DisableReferrerTrackingConfigOption::class, + Option\Tracking\DisableUaTrackingConfigOption::class, ], 'installation_commands' => [ - 'db_create_schema' => [ - 'command' => 'bin/cli db:create', + InstallationCommand::DB_CREATE_SCHEMA => [ + 'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME, ], - 'db_migrate' => [ - 'command' => 'bin/cli db:migrate', + InstallationCommand::DB_MIGRATE => [ + 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME, + ], + InstallationCommand::GEOLITE_DOWNLOAD_DB => [ + 'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME, ], ], ], diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index 1a404dca..72fafe58 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -4,8 +4,8 @@ declare(strict_types=1); use Laminas\ServiceManager\Proxy\LazyServiceFactory; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; -use Symfony\Component\Mercure\Publisher; -use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Hub; +use Symfony\Component\Mercure\HubInterface; return [ @@ -21,14 +21,14 @@ return [ LcobucciJwtProvider::class => [ LazyServiceFactory::class, ], - Publisher::class => [ + Hub::class => [ LazyServiceFactory::class, ], ], 'lazy_services' => [ 'class_map' => [ LcobucciJwtProvider::class => LcobucciJwtProvider::class, - Publisher::class => PublisherInterface::class, + Hub::class => HubInterface::class, ], ], ], diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php new file mode 100644 index 00000000..4fdf0ba6 --- /dev/null +++ b/config/autoload/tracking.global.php @@ -0,0 +1,31 @@ + [ + // Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations + // This applies only if IP address tracking is enabled + 'anonymize_remote_addr' => true, + + // Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence + 'track_orphan_visits' => true, + + // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence + 'disable_track_param' => null, + + // If true, visits will not be tracked at all + 'disable_tracking' => false, + + // If true, visits will be tracked, but neither the IP address, nor the location will be resolved + 'disable_ip_tracking' => false, + + // If true, the referrer will not be tracked + 'disable_referrer_tracking' => false, + + // If true, the user agent will not be tracked + 'disable_ua_tracking' => false, + ], + +]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 3751b1e9..d7cd8b02 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -14,13 +14,11 @@ return [ 'hostname' => '', ], 'validate_url' => false, // Deprecated - 'anonymize_remote_addr' => true, 'visits_webhooks' => [], 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, 'auto_resolve_titles' => false, - 'track_orphan_visits' => true, ], ]; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 3608257e..c2375cba 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -9,7 +9,8 @@ use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\Stdlib\Glob; -use PDO; +use Monolog\Handler\StreamHandler; +use Monolog\Logger; use PHPUnit\Runner\Version; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Driver\Selector; @@ -53,10 +54,6 @@ $buildDbConnection = function (): array { 'password' => 'root', 'dbname' => 'shlink_test', 'charset' => 'utf8', - 'driverOptions' => [ - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', - PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, - ], ], 'postgres' => [ 'driver' => 'pdo_pgsql', @@ -80,6 +77,18 @@ $buildDbConnection = function (): array { return $driverConfigMap[$driver] ?? []; }; +$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [ + 'handlers' => [ + $handlerName => [ + 'name' => StreamHandler::class, + 'params' => [ + 'level' => Logger::DEBUG, + 'stream' => sprintf('data/log/api-tests/%s', $filename), + ], + ], + ], +]; + return [ 'debug' => true, @@ -163,4 +172,9 @@ return [ ], ], + 'logger' => [ + 'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'), + 'Access' => $buildTestLoggerConfig('access_handler', 'access.log'), + ], + ]; diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index dc4930ec..8972e1ac 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,9 @@ -FROM php:8.0.2-fpm-alpine3.13 +FROM php:8.0.6-fpm-alpine3.13 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.19 ENV PDO_SQLSRV_VERSION 5.9.0 +ENV MS_ODBC_SQL_VERSION 17.5.2.1 RUN apk update @@ -44,13 +45,13 @@ RUN mkdir -p /usr/src/php/ext/apcu \ && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini # Install pcov and sqlsrv driver -RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ +RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ docker-php-ext-enable pdo_sqlsrv pcov && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk + rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk # Install composer COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 7cbfacb0..f0f2ca74 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,11 @@ -FROM php:8.0.2-alpine3.13 +FROM php:8.0.6-alpine3.13 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.19 ENV PDO_SQLSRV_VERSION 5.9.0 ENV INOTIFY_VERSION 3.0.0 -ENV SWOOLE_VERSION 4.6.3 +ENV SWOOLE_VERSION 4.6.7 +ENV MS_ODBC_SQL_VERSION 17.5.2.1 RUN apk update @@ -54,13 +55,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \ && rm /tmp/inotify.tar.gz # Install swoole, pcov and mssql driver -RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ +RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ docker-php-ext-enable swoole pdo_sqlsrv pcov && \ apk del .phpize-deps && \ - rm msodbcsql17_17.5.1.1-1_amd64.apk + rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk # Install composer COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php index 966a53a0..2901836e 100644 --- a/data/migrations/Version20160819142757.php +++ b/data/migrations/Version20160819142757.php @@ -41,12 +41,4 @@ class Version20160819142757 extends AbstractMigration { $db = $this->connection->getDatabasePlatform()->getName(); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20160820191203.php b/data/migrations/Version20160820191203.php index ae2b7bcf..d0a4d673 100644 --- a/data/migrations/Version20160820191203.php +++ b/data/migrations/Version20160820191203.php @@ -73,12 +73,4 @@ class Version20160820191203 extends AbstractMigration $schema->dropTable('short_urls_in_tags'); $schema->dropTable('tags'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20171021093246.php b/data/migrations/Version20171021093246.php index 54dd54cc..83f08e41 100644 --- a/data/migrations/Version20171021093246.php +++ b/data/migrations/Version20171021093246.php @@ -45,12 +45,4 @@ class Version20171021093246 extends AbstractMigration $shortUrls->dropColumn('valid_since'); $shortUrls->dropColumn('valid_until'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20171022064541.php b/data/migrations/Version20171022064541.php index 86efc008..7ff39666 100644 --- a/data/migrations/Version20171022064541.php +++ b/data/migrations/Version20171022064541.php @@ -42,12 +42,4 @@ class Version20171022064541 extends AbstractMigration $shortUrls->dropColumn('max_visits'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20180801183328.php b/data/migrations/Version20180801183328.php index 232b37b0..24bcd825 100644 --- a/data/migrations/Version20180801183328.php +++ b/data/migrations/Version20180801183328.php @@ -39,12 +39,4 @@ final class Version20180801183328 extends AbstractMigration { $schema->getTable('short_urls')->getColumn('short_code')->setLength($size); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index 187b860e..ee6cd861 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -66,12 +66,4 @@ final class Version20180913205455 extends AbstractMigration { // Nothing to rollback } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20180915110857.php b/data/migrations/Version20180915110857.php index 51a1ed72..73a36597 100644 --- a/data/migrations/Version20180915110857.php +++ b/data/migrations/Version20180915110857.php @@ -47,12 +47,4 @@ final class Version20180915110857 extends AbstractMigration { // Nothing to run } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20181020060559.php b/data/migrations/Version20181020060559.php index 804027d2..78cd8614 100644 --- a/data/migrations/Version20181020060559.php +++ b/data/migrations/Version20181020060559.php @@ -65,12 +65,4 @@ final class Version20181020060559 extends AbstractMigration { // No down } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20181020065148.php b/data/migrations/Version20181020065148.php index 4e33f8e9..62b14ccf 100644 --- a/data/migrations/Version20181020065148.php +++ b/data/migrations/Version20181020065148.php @@ -38,12 +38,4 @@ final class Version20181020065148 extends AbstractMigration { // No down } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20181110175521.php b/data/migrations/Version20181110175521.php index 7142522a..aae8d419 100644 --- a/data/migrations/Version20181110175521.php +++ b/data/migrations/Version20181110175521.php @@ -34,12 +34,4 @@ final class Version20181110175521 extends AbstractMigration { return $schema->getTable('visits')->getColumn('user_agent'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20190824075137.php b/data/migrations/Version20190824075137.php index 893ea4f5..208d85a3 100644 --- a/data/migrations/Version20190824075137.php +++ b/data/migrations/Version20190824075137.php @@ -34,12 +34,4 @@ final class Version20190824075137 extends AbstractMigration { return $schema->getTable('visits')->getColumn('referer'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20190930165521.php b/data/migrations/Version20190930165521.php index de11c3b5..2e4e8f50 100644 --- a/data/migrations/Version20190930165521.php +++ b/data/migrations/Version20190930165521.php @@ -52,12 +52,4 @@ final class Version20190930165521 extends AbstractMigration $schema->getTable('short_urls')->dropColumn('domain_id'); $schema->dropTable('domains'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20191001201532.php b/data/migrations/Version20191001201532.php index 045c65cc..d067101c 100644 --- a/data/migrations/Version20191001201532.php +++ b/data/migrations/Version20191001201532.php @@ -46,12 +46,4 @@ final class Version20191001201532 extends AbstractMigration $shortUrls->dropIndex('unique_short_code_plus_domain'); $shortUrls->addUniqueIndex(['short_code']); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20191020074522.php b/data/migrations/Version20191020074522.php index 638f60db..baf1ce7a 100644 --- a/data/migrations/Version20191020074522.php +++ b/data/migrations/Version20191020074522.php @@ -34,12 +34,4 @@ final class Version20191020074522 extends AbstractMigration { return $schema->getTable('short_urls')->getColumn('original_url'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20200105165647.php b/data/migrations/Version20200105165647.php index 3672322c..e0e31f55 100644 --- a/data/migrations/Version20200105165647.php +++ b/data/migrations/Version20200105165647.php @@ -93,12 +93,4 @@ final class Version20200105165647 extends AbstractMigration $visitLocations->dropColumn($colName); } } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20200106215144.php b/data/migrations/Version20200106215144.php index 6ed83522..5682baaf 100644 --- a/data/migrations/Version20200106215144.php +++ b/data/migrations/Version20200106215144.php @@ -44,12 +44,4 @@ final class Version20200106215144 extends AbstractMigration ]); } } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20200110182849.php b/data/migrations/Version20200110182849.php index 2d8e18c8..16b858f9 100644 --- a/data/migrations/Version20200110182849.php +++ b/data/migrations/Version20200110182849.php @@ -50,12 +50,4 @@ final class Version20200110182849 extends AbstractMigration { // No need (and no way) to undo this migration } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20200323190014.php b/data/migrations/Version20200323190014.php index 7adb31b3..47cf402a 100644 --- a/data/migrations/Version20200323190014.php +++ b/data/migrations/Version20200323190014.php @@ -42,12 +42,4 @@ final class Version20200323190014 extends AbstractMigration $visitLocations->dropColumn('is_empty'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20200503170404.php b/data/migrations/Version20200503170404.php index 4779b93b..a102c2c8 100644 --- a/data/migrations/Version20200503170404.php +++ b/data/migrations/Version20200503170404.php @@ -24,12 +24,4 @@ final class Version20200503170404 extends AbstractMigration $this->skipIf(! $visits->hasIndex(self::INDEX_NAME)); $visits->dropIndex(self::INDEX_NAME); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20201023090929.php b/data/migrations/Version20201023090929.php index 8c1e8622..05d16c22 100644 --- a/data/migrations/Version20201023090929.php +++ b/data/migrations/Version20201023090929.php @@ -41,12 +41,4 @@ final class Version20201023090929 extends AbstractMigration $shortUrls->dropColumn('import_original_short_code'); $shortUrls->dropIndex('unique_imports'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20201102113208.php b/data/migrations/Version20201102113208.php index 88be074c..1e1237a4 100644 --- a/data/migrations/Version20201102113208.php +++ b/data/migrations/Version20201102113208.php @@ -86,12 +86,4 @@ final class Version20201102113208 extends AbstractMigration $shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN); $shortUrls->dropColumn(self::API_KEY_COLUMN); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20210102174433.php b/data/migrations/Version20210102174433.php index 01616ffe..835fcbda 100644 --- a/data/migrations/Version20210102174433.php +++ b/data/migrations/Version20210102174433.php @@ -49,12 +49,4 @@ final class Version20210102174433 extends AbstractMigration $schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key'); $schema->dropTable(self::TABLE_NAME); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20210118153932.php b/data/migrations/Version20210118153932.php index e9f29759..e17ff533 100644 --- a/data/migrations/Version20210118153932.php +++ b/data/migrations/Version20210118153932.php @@ -23,12 +23,4 @@ final class Version20210118153932 extends AbstractMigration public function down(Schema $schema): void { } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20210202181026.php b/data/migrations/Version20210202181026.php index c964559c..ccf69572 100644 --- a/data/migrations/Version20210202181026.php +++ b/data/migrations/Version20210202181026.php @@ -33,12 +33,4 @@ final class Version20210202181026 extends AbstractMigration $shortUrls->dropColumn(self::TITLE); $shortUrls->dropColumn('title_was_auto_resolved'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php index 24e73d34..4c4509c4 100644 --- a/data/migrations/Version20210207100807.php +++ b/data/migrations/Version20210207100807.php @@ -40,12 +40,4 @@ final class Version20210207100807 extends AbstractMigration $visits->dropColumn('visited_url'); $visits->dropColumn('type'); } - - /** - * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104 - */ - public function isTransactional(): bool - { - return false; - } } diff --git a/data/migrations/Version20210306165711.php b/data/migrations/Version20210306165711.php new file mode 100644 index 00000000..5b4bd166 --- /dev/null +++ b/data/migrations/Version20210306165711.php @@ -0,0 +1,37 @@ +getTable(self::TABLE); + $this->skipIf($apiKeys->hasColumn(self::COLUMN)); + + $apiKeys->addColumn( + self::COLUMN, + Types::STRING, + [ + 'notnull' => false, + ], + ); + } + + public function down(Schema $schema): void + { + $apiKeys = $schema->getTable(self::TABLE); + $this->skipIf(! $apiKeys->hasColumn(self::COLUMN)); + + $apiKeys->dropColumn(self::COLUMN); + } +} diff --git a/data/migrations/Version20210522051601.php b/data/migrations/Version20210522051601.php new file mode 100644 index 00000000..9e2bd19e --- /dev/null +++ b/data/migrations/Version20210522051601.php @@ -0,0 +1,26 @@ +getTable('short_urls'); + $this->skipIf($shortUrls->hasColumn('crawlable')); + $shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]); + } + + public function down(Schema $schema): void + { + $shortUrls = $schema->getTable('short_urls'); + $this->skipIf(! $shortUrls->hasColumn('crawlable')); + $shortUrls->dropColumn('crawlable'); + } +} diff --git a/data/migrations/Version20210522124633.php b/data/migrations/Version20210522124633.php new file mode 100644 index 00000000..ea486e93 --- /dev/null +++ b/data/migrations/Version20210522124633.php @@ -0,0 +1,28 @@ +getTable('visits'); + $this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); + $visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); + $visits->dropColumn(self::POTENTIAL_BOT_COLUMN); + } +} diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 4ddd52e5..2a8369d7 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -42,12 +42,6 @@ $helper = new class { ]; } - $driverOptions = ! $isMysql ? [] : [ - // 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND - 1002 => 'SET NAMES utf8', - // 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY - 1000 => true, - ]; return [ 'driver' => self::DB_DRIVERS_MAP[$driver], 'dbname' => env('DB_NAME', 'shlink'), @@ -55,7 +49,6 @@ $helper = new class { 'password' => env('DB_PASSWORD'), 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null), 'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]), - 'driverOptions' => $driverOptions, 'unix_socket' => $isMysql ? env('DB_UNIX_SOCKET') : null, ]; } @@ -101,10 +94,6 @@ $helper = new class { return [ - 'app_options' => [ - 'disable_track_param' => env('DISABLE_TRACK_PARAM'), - ], - 'delete_short_urls' => [ 'check_visits_threshold' => true, 'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD), @@ -120,13 +109,21 @@ return [ 'hostname' => env('SHORT_DOMAIN_HOST', ''), ], 'validate_url' => (bool) env('VALIDATE_URLS', false), - 'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true), 'visits_webhooks' => $helper->getVisitsWebhooks(), 'default_short_codes_length' => $helper->getDefaultShortCodesLength(), 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), + ], + + 'tracking' => [ + 'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true), 'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true), + 'disable_track_param' => env('DISABLE_TRACK_PARAM'), + 'disable_tracking' => (bool) env('DISABLE_TRACKING', false), + 'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false), + 'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false), + 'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false), ], 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), @@ -170,7 +167,7 @@ return [ ], 'geolite2' => [ - 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), + 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove the default value ], 'mercure' => $helper->getMercureConfig(), diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index df480d2f..1f9337c4 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -15,6 +15,12 @@ php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q echo "Clearing entities cache..." php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q +# Try to download GeoLite2 db file only if the license key env var was defined +if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then + echo "Downloading GeoLite2 db file..." + php bin/cli visit:download-db -n -q +fi + # When restarting the container, swoole might think it is already in execution # This forces the app to be started every second until the exit code is 0 until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index df9bc6d6..0b546377 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -116,6 +116,15 @@ "domain": { "type": "string", "description": "The domain in which the short URL was created. Null if it belongs to default domain." + }, + "title": { + "type": "string", + "nullable": true, + "description": "A descriptive title of the short URL." + }, + "crawlable": { + "type": "boolean", + "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." } }, "example": { @@ -133,7 +142,9 @@ "validUntil": null, "maxVisits": 100 }, - "domain": "example.com" + "domain": "example.com", + "title": "The title", + "crawlable": false } }, "ShortUrlMeta": { @@ -179,6 +190,10 @@ }, "visitLocation": { "$ref": "#/components/schemas/VisitLocation" + }, + "potentialBot": { + "type": "boolean", + "description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler" } }, "example": { @@ -193,7 +208,8 @@ "longitude": -122.0946, "regionName": "California", "timezone": "America/Los_Angeles" - } + }, + "potentialBot": false } }, "OrphanVisit": { @@ -232,6 +248,7 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, + "potentialBot": false, "visitedUrl": "https://doma.in", "type": "base_url" } diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 3e4c6ead..b2ffd3f6 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -41,6 +41,10 @@ "type": "string", "nullable": true, "description": "A descriptive title of the short URL." + }, + "crawlable": { + "type": "boolean", + "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." } } } diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index e004e4fe..ecb6b9f9 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -17,6 +17,10 @@ }, "visitLocation": { "$ref": "./VisitLocation.json" + }, + "potentialBot": { + "type": "boolean", + "description": "Tells if Shlink thinks this visit comes potentially from a bot or crawler" } } } diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index b034dcf3..8cf22045 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -140,7 +140,8 @@ "maxVisits": 100 }, "domain": null, - "title": "Welcome to Steam" + "title": "Welcome to Steam", + "crawlable": false }, { "shortCode": "12Kb3", @@ -157,7 +158,8 @@ "maxVisits": null }, "domain": null, - "title": null + "title": null, + "crawlable": false }, { "shortCode": "123bA", @@ -172,7 +174,8 @@ "maxVisits": null }, "domain": "example.com", - "title": null + "title": null, + "crawlable": false } ], "pagination": { @@ -273,6 +276,10 @@ "title": { "type": "string", "description": "A descriptive title of the short URL." + }, + "crawlable": { + "type": "boolean", + "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." } } } @@ -305,7 +312,9 @@ "validUntil": null, "maxVisits": 500 }, - "domain": null + "domain": null, + "title": null, + "crawlable": false } } }, diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index b6184d8d..90c3eda5 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -74,7 +74,8 @@ "maxVisits": 100 }, "domain": null, - "title": null + "title": null, + "crawlable": false }, "text/plain": "https://doma.in/abc123" } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 2281d9b8..8691c0b5 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -54,7 +54,8 @@ "maxVisits": 100 }, "domain": null, - "title": null + "title": null, + "crawlable": false } } }, @@ -147,6 +148,10 @@ "type": "string", "description": "A descriptive title of the short URL.", "nullable": true + }, + "crawlable": { + "type": "boolean", + "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." } } } @@ -184,7 +189,8 @@ "maxVisits": 100 }, "domain": null, - "title": "Shlink - The URL shortener" + "title": "Shlink - The URL shortener", + "crawlable": false } } }, diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index 03d66a99..e5bbbe86 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -57,6 +57,16 @@ "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": [ @@ -98,7 +108,8 @@ "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 + "visitLocation": null, + "potentialBot": false }, { "referer": "https://t.co", @@ -112,13 +123,15 @@ "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 + "visitLocation": null, + "potentialBot": true } ], "pagination": { diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index d9d9dda7..df1242f6 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -54,6 +54,16 @@ "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": [ @@ -95,7 +105,8 @@ "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 + "visitLocation": null, + "potentialBot": false }, { "referer": "https://t.co", @@ -109,13 +120,15 @@ "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 + "visitLocation": null, + "potentialBot": true } ], "pagination": { diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index 683f40ec..ce52b197 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -45,6 +45,16 @@ "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": [ @@ -87,6 +97,7 @@ "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, "visitedUrl": "https://doma.in", "type": "base_url" }, @@ -103,6 +114,7 @@ "regionName": "California", "timezone": "America/Los_Angeles" }, + "potentialBot": false, "visitedUrl": "https://doma.in/foo", "type": "invalid_short_url" }, @@ -111,6 +123,7 @@ "date": "2015-08-20T05:05:03+04:00", "userAgent": "some_web_crawler/1.4", "visitLocation": null, + "potentialBot": true, "visitedUrl": "https://doma.in/foo/bar/baz", "type": "regular_404" } diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 6e32428a..6043833b 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -15,6 +15,7 @@ return [ Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, + Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 80b26b8d..7d7e2865 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -44,6 +44,7 @@ return [ Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, + Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, @@ -80,11 +81,11 @@ return [ Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], + Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class], Command\Visit\LocateVisitsCommand::class => [ Visit\VisitLocator::class, IpLocationResolverInterface::class, LockFactory::class, - Util\GeolocationDbUpdater::class, ], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 119fa020..31df82a1 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -42,6 +42,10 @@ class GenerateKeyCommand extends BaseCommand %command.full_name% + You can optionally set its name for tracking purposes with --name or -m: + + %command.full_name% --name Alice + You can optionally set its expiration date with --expiration-date or -e: %command.full_name% --expiration-date 2020-01-01 @@ -56,6 +60,12 @@ class GenerateKeyCommand extends BaseCommand $this ->setName(self::NAME) ->setDescription('Generates a new valid API key.') + ->addOption( + 'name', + 'm', + InputOption::VALUE_REQUIRED, + 'The name by which this API key will be known.', + ) ->addOptionWithDeprecatedFallback( 'expiration-date', 'e', @@ -82,6 +92,7 @@ class GenerateKeyCommand extends BaseCommand $expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date'); $apiKey = $this->apiKeyService->create( isset($expirationDate) ? Chronos::parse($expirationDate) : null, + $input->getOption('name'), ...$this->roleResolver->determineRoles($input), ); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 9243779b..e8326826 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -57,7 +57,7 @@ class ListKeysCommand extends BaseCommand $messagePattern = $this->determineMessagePattern($apiKey); // Set columns for this row - $rowData = [sprintf($messagePattern, $apiKey)]; + $rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name() ?? '-')]; if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } @@ -74,10 +74,12 @@ class ListKeysCommand extends BaseCommand ShlinkTable::fromOutput($output)->render(array_filter([ 'Key', + 'Name', ! $enabledOnly ? 'Is enabled' : null, 'Expiration date', 'Roles', ]), $rows); + return ExitCodes::EXIT_SUCCESS; } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 24689bcb..0d637f5f 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; @@ -19,6 +20,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function array_keys; use function array_pad; use function explode; use function Functional\map; @@ -30,18 +32,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand use PagerfantaUtilsTrait; public const NAME = 'short-url:list'; - private const COLUMNS_TO_SHOW = [ - 'shortCode', - 'title', - 'shortUrl', - 'longUrl', - 'dateCreated', - 'visitsCount', - ]; - private const COLUMNS_TO_SHOW_WITH_TAGS = [ - ...self::COLUMNS_TO_SHOW, - 'tags', - ]; private ShortUrlServiceInterface $shortUrlService; private DataTransformerInterface $transformer; @@ -90,6 +80,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand InputOption::VALUE_NONE, 'Whether to display the tags or not.', ) + ->addOption( + 'show-api-key', + 'k', + InputOption::VALUE_NONE, + 'Whether to display the API key from which the URL was generated or not.', + ) + ->addOption( + 'show-api-key-name', + 'm', + InputOption::VALUE_NONE, + 'Whether to display the API key name from which the URL was generated or not.', + ) ->addOption( 'all', 'a', @@ -117,11 +119,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term'); $tags = $input->getOption('tags'); $tags = ! empty($tags) ? explode(',', $tags) : []; - $showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags'); $all = $input->getOption('all'); $startDate = $this->getStartDateOption($input, $output); $endDate = $this->getEndDateOption($input, $output); $orderBy = $this->processOrderBy($input); + $columnsMap = $this->resolveColumnsMap($input); $data = [ ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, @@ -137,7 +139,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand do { $data[ShortUrlsParamsInputFilter::PAGE] = $page; - $result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all); + $result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all); $page++; $continue = $result->hasNextPage() && $io->confirm( @@ -152,32 +154,26 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand return ExitCodes::EXIT_SUCCESS; } - private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params, bool $all): Paginator - { - $result = $this->shortUrlService->listShortUrls($params); + private function renderPage( + OutputInterface $output, + array $columnsMap, + ShortUrlsParams $params, + bool $all + ): Paginator { + $shortUrls = $this->shortUrlService->listShortUrls($params); - $headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count']; - if ($showTags) { - $headers[] = 'Tags'; - } + $rows = map($shortUrls, function (ShortUrl $shortUrl) use ($columnsMap) { + $rawShortUrl = $this->transformer->transform($shortUrl); + return map($columnsMap, fn (callable $call) => $call($rawShortUrl, $shortUrl)); + }); - $rows = []; - foreach ($result as $row) { - $columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW; - $shortUrl = $this->transformer->transform($row); - if ($showTags) { - $shortUrl['tags'] = implode(', ', $shortUrl['tags']); - } + ShlinkTable::fromOutput($output)->render( + array_keys($columnsMap), + $rows, + $all ? null : $this->formatCurrentPageMessage($shortUrls, 'Page %s of %s'), + ); - $rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]); - } - - ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage( - $result, - 'Page %s of %s', - )); - - return $result; + return $shortUrls; } private function processOrderBy(InputInterface $input): ?string @@ -190,4 +186,33 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand [$field, $dir] = array_pad(explode(',', $orderBy), 2, null); return $dir === null ? $field : sprintf('%s-%s', $field, $dir); } + + private function resolveColumnsMap(InputInterface $input): array + { + $pickProp = static fn (string $prop): callable => static fn (array $shortUrl) => $shortUrl[$prop]; + $columnsMap = [ + 'Short Code' => $pickProp('shortCode'), + 'Title' => $pickProp('title'), + 'Short URL' => $pickProp('shortUrl'), + 'Long URL' => $pickProp('longUrl'), + 'Date created' => $pickProp('dateCreated'), + 'Visits count' => $pickProp('visitsCount'), + ]; + if ($this->getOptionWithDeprecatedFallback($input, 'show-tags')) { + $columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']); + } + if ($input->getOption('show-api-key')) { + $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => + (string) $shortUrl->authorApiKey(); + } + if ($input->getOption('show-api-key-name')) { + $columnsMap['API Key Name'] = static function (array $_, ShortUrl $shortUrl): ?string { + $apiKey = $shortUrl->authorApiKey(); + + return $apiKey !== null ? $apiKey->name() : null; + }; + } + + return $columnsMap; + } } diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php new file mode 100644 index 00000000..3d76663a --- /dev/null +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -0,0 +1,80 @@ +dbUpdater = $dbUpdater; + } + + protected function configure(): void + { + $this + ->setName(self::NAME) + ->setDescription( + 'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date ' + . 'copy if so.', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): ?int + { + $io = new SymfonyStyle($input, $output); + + try { + $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void { + $io->text(sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading')); + $this->progressBar = new ProgressBar($io); + }, function (int $total, int $downloaded): void { + $this->progressBar->setMaxSteps($total); + $this->progressBar->setProgress($downloaded); + }); + + if ($this->progressBar === null) { + $io->info('GeoLite2 db file is up to date.'); + } else { + $this->progressBar->finish(); + $io->success('GeoLite2 db file properly downloaded.'); + } + + return ExitCodes::EXIT_SUCCESS; + } catch (GeolocationDbUpdateFailedException $e) { + $olderDbExists = $e->olderDbExists(); + + if ($olderDbExists) { + $io->warning( + 'GeoLite2 db file update failed. Visits will continue to be located with the old version.', + ); + } else { + $io->error('GeoLite2 db file download failed. It will not be possible to locate visits.'); + } + + if ($io->isVerbose()) { + $this->getApplication()->renderThrowable($e, $io); + } + + return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE; + } + } +} diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index 67678d4d..0bcfb1d7 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -6,9 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand; use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig; -use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; @@ -19,7 +17,6 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Exception\RuntimeException; -use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -35,28 +32,26 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat private VisitLocatorInterface $visitLocator; private IpLocationResolverInterface $ipLocationResolver; - private GeolocationDbUpdaterInterface $dbUpdater; private SymfonyStyle $io; - private ?ProgressBar $progressBar = null; public function __construct( VisitLocatorInterface $visitLocator, IpLocationResolverInterface $ipLocationResolver, - LockFactory $locker, - GeolocationDbUpdaterInterface $dbUpdater + LockFactory $locker ) { parent::__construct($locker); $this->visitLocator = $visitLocator; $this->ipLocationResolver = $ipLocationResolver; - $this->dbUpdater = $dbUpdater; } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Resolves visits origin locations.') + ->setDescription( + 'Resolves visits origin locations. It implicitly downloads/updates the GeoLite2 db file if needed.', + ) ->addOption( 'retry', 'r', @@ -90,12 +85,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat ); } - if ($all && $retry && ! $this->warnAndVerifyContinue()) { + if ($all && $retry && ! $this->warnAndVerifyContinue($input)) { throw new RuntimeException('Execution aborted'); } } - private function warnAndVerifyContinue(): bool + private function warnAndVerifyContinue(InputInterface $input): bool { $this->io->warning([ 'You are about to process the location of all existing visits your short URLs received.', @@ -113,7 +108,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $all = $retry && $input->getOption('all'); try { - $this->checkDbUpdate(); + $this->checkDbUpdate($input); if ($all) { $this->visitLocator->locateAllVisits($this); @@ -128,7 +123,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat return ExitCodes::EXIT_SUCCESS; } catch (Throwable $e) { $this->io->error($e->getMessage()); - if ($e instanceof Throwable && $this->io->isVerbose()) { + if ($this->io->isVerbose()) { $this->getApplication()->renderThrowable($e, $this->io); } @@ -176,33 +171,13 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $this->io->writeln($message); } - private function checkDbUpdate(): void + private function checkDbUpdate(InputInterface $input): void { - try { - $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void { - $this->io->writeln( - sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'), - ); - $this->progressBar = new ProgressBar($this->io); - }, function (int $total, int $downloaded): void { - $this->progressBar->setMaxSteps($total); - $this->progressBar->setProgress($downloaded); - }); + $downloadDbCommand = $this->getApplication()->find(DownloadGeoLiteDbCommand::NAME); + $exitCode = $downloadDbCommand->run($input, $this->io); - if ($this->progressBar !== null) { - $this->progressBar->finish(); - $this->io->newLine(); - } - } catch (GeolocationDbUpdateFailedException $e) { - if (! $e->olderDbExists()) { - $this->io->error('GeoLite2 database download failed. It is not possible to locate visits.'); - throw $e; - } - - $this->io->newLine(); - $this->io->writeln( - '[Warning] GeoLite2 database update failed. Proceeding with old version.', - ); + if ($exitCode === ExitCodes::EXIT_FAILURE) { + throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index f663fd8f..07d66855 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,6 +13,11 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; + private function __construct(string $message, int $code = 0, ?Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + public static function withOlderDb(?Throwable $prev = null): self { $e = new self( diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index b8f5b756..6e7c2da2 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -32,13 +32,13 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void + public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void { $lock = $this->locker->createLock(self::LOCK_NAME); $lock->acquire(true); // Block until lock is released try { - $this->downloadIfNeeded($mustBeUpdated, $handleProgress); + $this->downloadIfNeeded($beforeDownload, $handleProgress); } finally { $lock->release(); } @@ -47,34 +47,16 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadIfNeeded(?callable $mustBeUpdated, ?callable $handleProgress): void + private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): void { if (! $this->dbUpdater->databaseFileExists()) { - $this->downloadNewDb(false, $mustBeUpdated, $handleProgress); + $this->downloadNewDb(false, $beforeDownload, $handleProgress); return; } $meta = $this->geoLiteDbReader->metadata(); if ($this->buildIsTooOld($meta)) { - $this->downloadNewDb(true, $mustBeUpdated, $handleProgress); - } - } - - /** - * @throws GeolocationDbUpdateFailedException - */ - private function downloadNewDb(bool $olderDbExists, ?callable $mustBeUpdated, ?callable $handleProgress): void - { - if ($mustBeUpdated !== null) { - $mustBeUpdated($olderDbExists); - } - - try { - $this->dbUpdater->downloadFreshCopy($handleProgress); - } catch (RuntimeException $e) { - throw $olderDbExists - ? GeolocationDbUpdateFailedException::withOlderDb($e) - : GeolocationDbUpdateFailedException::withoutOlderDb($e); + $this->downloadNewDb(true, $beforeDownload, $handleProgress); } } @@ -105,4 +87,31 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch); } + + /** + * @throws GeolocationDbUpdateFailedException + */ + private function downloadNewDb(bool $olderDbExists, ?callable $beforeDownload, ?callable $handleProgress): void + { + if ($beforeDownload !== null) { + $beforeDownload($olderDbExists); + } + + try { + $this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists)); + } catch (RuntimeException $e) { + throw $olderDbExists + ? GeolocationDbUpdateFailedException::withOlderDb($e) + : GeolocationDbUpdateFailedException::withoutOlderDb($e); + } + } + + private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable + { + if ($handleProgress === null) { + return null; + } + + return fn (int $total, int $downloaded) => $handleProgress($total, $downloaded, $olderDbExists); + } } diff --git a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php b/module/CLI/src/Util/GeolocationDbUpdaterInterface.php index 1eda5123..714f6a11 100644 --- a/module/CLI/src/Util/GeolocationDbUpdaterInterface.php +++ b/module/CLI/src/Util/GeolocationDbUpdaterInterface.php @@ -11,5 +11,5 @@ interface GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $mustBeUpdated = null, ?callable $handleProgress = null): void; + public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): void; } diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/CliTestUtilsTrait.php new file mode 100644 index 00000000..412131dc --- /dev/null +++ b/module/CLI/test/CliTestUtilsTrait.php @@ -0,0 +1,44 @@ +prophesize(Command::class); + $command->getName()->willReturn($name); + $command->getDefinition()->willReturn($name); + $command->isEnabled()->willReturn(true); + $command->getAliases()->willReturn([]); + $command->setApplication(Argument::type(Application::class))->willReturn(function (): void { + }); + + return $command; + } + + private function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester + { + $app = new Application(); + $app->add($mainCommand); + foreach ($extraCommands as $command) { + $app->add($command); + } + + return new CommandTester($mainCommand); + } +} diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 49835f85..90942dc9 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -5,17 +5,16 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class DisableKeyCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -23,10 +22,7 @@ class DisableKeyCommandTest extends TestCase public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $command = new DisableKeyCommand($this->apiKeyService->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 00548f17..e5c543d5 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -7,55 +7,64 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Tester\CommandTester; class GenerateKeyCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; - private ObjectProphecy $roleResolver; public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $this->roleResolver = $this->prophesize(RoleResolverInterface::class); - $this->roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); + $roleResolver = $this->prophesize(RoleResolverInterface::class); + $roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); - $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $this->roleResolver->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal()); + $this->commandTester = $this->testerForCommand($command); } /** @test */ public function noExpirationDateIsDefinedIfNotProvided(): void { - $create = $this->apiKeyService->create(null)->willReturn(new ApiKey()); + $this->apiKeyService->create(null, null)->shouldBeCalledOnce()->willReturn(ApiKey::create()); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Generated API key: ', $output); - $create->shouldHaveBeenCalledOnce(); } /** @test */ public function expirationDateIsDefinedIfProvided(): void { - $this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce() - ->willReturn(new ApiKey()); + $this->apiKeyService->create(Argument::type(Chronos::class), null)->shouldBeCalledOnce()->willReturn( + ApiKey::create(), + ); + $this->commandTester->execute([ '--expiration-date' => '2016-01-01', ]); } + + /** @test */ + public function nameIsDefinedIfProvided(): void + { + $this->apiKeyService->create(null, Argument::type('string'))->shouldBeCalledOnce()->willReturn( + ApiKey::create(), + ); + + $this->commandTester->execute([ + '--name' => 'Alice', + ]); + } } diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index e0cada5d..fc845ff7 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -5,19 +5,19 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListKeysCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -25,10 +25,7 @@ class ListKeysCommandTest extends TestCase public function setUp(): void { $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $command = new ListKeysCommand($this->apiKeyService->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal())); } /** @@ -49,65 +46,85 @@ class ListKeysCommandTest extends TestCase public function provideKeysAndOutputs(): iterable { yield 'all keys' => [ - [ApiKey::withKey('foo'), ApiKey::withKey('bar'), ApiKey::withKey('baz')], + [$apiKey1 = ApiKey::create(), $apiKey2 = ApiKey::create(), $apiKey3 = ApiKey::create()], false, << [ - [ApiKey::withKey('foo')->disable(), ApiKey::withKey('bar')], + [$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()], true, << [ [ - ApiKey::withKey('foo'), - $this->apiKeyWithRoles('bar', [RoleDefinition::forAuthoredShortUrls()]), - $this->apiKeyWithRoles('baz', [RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]), - ApiKey::withKey('foo2'), - $this->apiKeyWithRoles('baz2', [ + $apiKey1 = ApiKey::create(), + $apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]), + $apiKey3 = $this->apiKeyWithRoles([RoleDefinition::forDomain((new Domain('example.com'))->setId('1'))]), + $apiKey4 = ApiKey::create(), + $apiKey5 = $this->apiKeyWithRoles([ RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain((new Domain('example.com'))->setId('1')), ]), - ApiKey::withKey('foo3'), + $apiKey6 = ApiKey::create(), ], true, << [ + [ + $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice')), + $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice and Bob')), + $apiKey3 = ApiKey::fromMeta(ApiKeyMeta::withName('')), + $apiKey4 = ApiKey::create(), + ], + true, + <<registerRole($role); } diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index db9dcf66..70d4d5eb 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -9,11 +9,10 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -22,7 +21,7 @@ use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $processHelper; @@ -59,10 +58,8 @@ class CreateDatabaseCommandTest extends TestCase $this->regularConn->reveal(), $noDbNameConn->reveal(), ); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index d25f44f2..d301f55e 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; @@ -19,7 +18,7 @@ use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $processHelper; @@ -43,10 +42,7 @@ class MigrateDatabaseCommandTest extends TestCase $this->processHelper->reveal(), $phpExecutableFinder->reveal(), ); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index a0f79448..04f7eb5d 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -5,18 +5,17 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Domain; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListDomainsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $domainService; @@ -24,12 +23,7 @@ class ListDomainsCommandTest extends TestCase public function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - - $command = new ListDomainsCommand($this->domainService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 83fd792d..a6b6fc78 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -6,13 +6,12 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function array_pop; @@ -22,7 +21,7 @@ use const PHP_EOL; class DeleteShortUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $service; @@ -30,12 +29,7 @@ class DeleteShortUrlCommandTest extends TestCase public function setUp(): void { $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); - - $command = new DeleteShortUrlCommand($this->service->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 25953d38..19767dc7 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; @@ -17,12 +16,12 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class GenerateShortUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $urlShortener; @@ -35,9 +34,7 @@ class GenerateShortUrlCommandTest extends TestCase $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index d25d5763..b9262217 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; @@ -21,14 +20,14 @@ use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; class GetVisitsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $visitsHelper; @@ -37,9 +36,7 @@ class GetVisitsCommandTest extends TestCase { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); $command = new GetVisitsCommand($this->visitsHelper->reveal()); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ @@ -106,7 +103,7 @@ class GetVisitsCommandTest extends TestCase $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( new Paginator(new ArrayAdapter([ Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( - new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')), + VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')), ), ])), )->shouldBeCalledOnce(); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 3f2b38b1..6f7b11a6 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -8,23 +8,26 @@ use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; -use Symfony\Component\Console\Application; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; +use Shlinkio\Shlink\Rest\Entity\ApiKey; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; +use function count; use function explode; class ListShortUrlsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $shortUrlService; @@ -32,12 +35,10 @@ class ListShortUrlsCommandTest extends TestCase public function setUp(): void { $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $app = new Application(); $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand($command); } /** @test */ @@ -101,17 +102,77 @@ class ListShortUrlsCommandTest extends TestCase $this->commandTester->execute(['--page' => $page]); } - /** @test */ - public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void - { + /** + * @test + * @dataProvider provideOptionalFlags + */ + public function provideOptionalFlagsMakesNewColumnsToBeIncluded( + array $input, + array $expectedContents, + array $notExpectedContents, + ApiKey $apiKey + ): void { $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) - ->willReturn(new Paginator(new ArrayAdapter([]))) + ->willReturn(new Paginator(new ArrayAdapter([ + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo.com', + 'tags' => ['foo', 'bar', 'baz'], + 'apiKey' => $apiKey, + ])), + ]))) ->shouldBeCalledOnce(); $this->commandTester->setInputs(['y']); - $this->commandTester->execute(['--show-tags' => true]); + $this->commandTester->execute($input); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString('Tags', $output); + + if (count($expectedContents) === 0 && count($notExpectedContents) === 0) { + self::fail('No expectations were run'); + } + + foreach ($expectedContents as $column) { + self::assertStringContainsString($column, $output); + } + foreach ($notExpectedContents as $column) { + self::assertStringNotContainsString($column, $output); + } + } + + public function provideOptionalFlags(): iterable + { + $apiKey = ApiKey::fromMeta(ApiKeyMeta::withName('my api key')); + $key = $apiKey->toString(); + + yield 'tags only' => [ + ['--show-tags' => true], + ['| Tags ', '| foo, bar, baz'], + ['| API Key ', '| API Key Name |', $key, '| my api key'], + $apiKey, + ]; + yield 'api key only' => [ + ['--show-api-key' => true], + ['| API Key ', $key], + ['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key'], + $apiKey, + ]; + yield 'api key name only' => [ + ['--show-api-key-name' => true], + ['| API Key Name |', '| my api key'], + ['| Tags ', '| foo, bar, baz', '| API Key ', $key], + $apiKey, + ]; + yield 'tags and api key' => [ + ['--show-tags' => true, '--show-api-key' => true], + ['| API Key ', '| Tags ', '| foo, bar, baz', $key], + ['| API Key Name |', '| my api key'], + $apiKey, + ]; + yield 'all' => [ + ['--show-tags' => true, '--show-api-key' => true, '--show-api-key-name' => true], + ['| API Key ', '| Tags ', '| API Key Name |', '| foo, bar, baz', $key, '| my api key'], + [], + $apiKey, + ]; } /** diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index f0025b65..2a816207 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; use function sprintf; @@ -21,7 +20,7 @@ use const PHP_EOL; class ResolveUrlCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $urlResolver; @@ -29,11 +28,7 @@ class ResolveUrlCommandTest extends TestCase public function setUp(): void { $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $command = new ResolveUrlCommand($this->urlResolver->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index 2789c481..7062cb45 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -6,16 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class CreateTagCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -23,12 +22,7 @@ class CreateTagCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new CreateTagCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new CreateTagCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 6d3737c1..46f61814 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -5,16 +5,15 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class DeleteTagsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -22,12 +21,7 @@ class DeleteTagsCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new DeleteTagsCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 5b9e14e9..9ec42e54 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -5,18 +5,17 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class ListTagsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -24,12 +23,7 @@ class ListTagsCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new ListTagsCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index d457c25d..3a52aba3 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -5,19 +5,18 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; class RenameTagCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -25,12 +24,7 @@ class RenameTagCommandTest extends TestCase public function setUp(): void { $this->tagService = $this->prophesize(TagServiceInterface::class); - - $command = new RenameTagCommand($this->tagService->reveal()); - $app = new Application(); - $app->add($command); - - $this->commandTester = new CommandTester($command); + $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal())); } /** @test */ diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php new file mode 100644 index 00000000..7ead517d --- /dev/null +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -0,0 +1,107 @@ +dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); + $this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater->reveal())); + } + + /** + * @test + * @dataProvider provideFailureParams + */ + public function showsProperMessageWhenGeoLiteUpdateFails( + bool $olderDbExists, + string $expectedMessage, + int $expectedExitCode + ): void { + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( + function (array $args) use ($olderDbExists): void { + [$beforeDownload, $handleProgress] = $args; + + $beforeDownload($olderDbExists); + $handleProgress(100, 50); + + throw $olderDbExists + ? GeolocationDbUpdateFailedException::withOlderDb() + : GeolocationDbUpdateFailedException::withoutOlderDb(); + }, + ); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + $exitCode = $this->commandTester->getStatusCode(); + + self::assertStringContainsString( + sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'), + $output, + ); + self::assertStringContainsString($expectedMessage, $output); + self::assertSame($expectedExitCode, $exitCode); + $checkDbUpdate->shouldHaveBeenCalledOnce(); + } + + public function provideFailureParams(): iterable + { + yield 'existing db' => [ + true, + '[WARNING] GeoLite2 db file update failed. Visits will continue to be located', + ExitCodes::EXIT_WARNING, + ]; + yield 'not existing db' => [ + false, + '[ERROR] GeoLite2 db file download failed. It will not be possible to locate', + ExitCodes::EXIT_FAILURE, + ]; + } + + /** + * @test + * @dataProvider provideSuccessParams + */ + public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void + { + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will($checkUpdateBehavior); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + $exitCode = $this->commandTester->getStatusCode(); + + self::assertStringContainsString($expectedMessage, $output); + self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode); + $checkDbUpdate->shouldHaveBeenCalledOnce(); + } + + public function provideSuccessParams(): iterable + { + yield 'up to date db' => [function (): void { + }, '[INFO] GeoLite2 db file is up to date.']; + yield 'outdated db' => [function (array $args): void { + [$beforeDownload] = $args; + $beforeDownload(true); + }, '[OK] GeoLite2 db file properly downloaded.']; + } +} diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index d5ee2982..74148f9c 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -6,11 +6,10 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit; use PHPUnit\Framework\TestCase; use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; -use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; +use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; @@ -21,7 +20,7 @@ use Shlinkio\Shlink\Core\Visit\VisitLocator; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use Symfony\Component\Console\Application; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; @@ -33,19 +32,18 @@ use const PHP_EOL; class LocateVisitsCommandTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private CommandTester $commandTester; private ObjectProphecy $visitService; private ObjectProphecy $ipResolver; private ObjectProphecy $lock; - private ObjectProphecy $dbUpdater; + private ObjectProphecy $downloadDbCommand; public function setUp(): void { $this->visitService = $this->prophesize(VisitLocator::class); $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); @@ -58,12 +56,12 @@ class LocateVisitsCommandTest extends TestCase $this->visitService->reveal(), $this->ipResolver->reveal(), $locker->reveal(), - $this->dbUpdater->reveal(), ); - $app = new Application(); - $app->add($command); - $this->commandTester = new CommandTester($command); + $this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME); + $this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_SUCCESS); + + $this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand->reveal()); } /** @@ -78,7 +76,7 @@ class LocateVisitsCommandTest extends TestCase array $args ): void { $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); - $location = new VisitLocation(Location::emptyInstance()); + $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior); @@ -122,7 +120,7 @@ class LocateVisitsCommandTest extends TestCase public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void { $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, '')); - $location = new VisitLocation(Location::emptyInstance()); + $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( $this->invokeHelperMethods($visit, $location), @@ -155,7 +153,7 @@ class LocateVisitsCommandTest extends TestCase public function errorWhileLocatingIpIsDisplayed(): void { $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); - $location = new VisitLocation(Location::emptyInstance()); + $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( $this->invokeHelperMethods($visit, $location), @@ -202,43 +200,16 @@ class LocateVisitsCommandTest extends TestCase $resolveIpLocation->shouldNotHaveBeenCalled(); } - /** - * @test - * @dataProvider provideParams - */ - public function showsProperMessageWhenGeoLiteUpdateFails(bool $olderDbExists, string $expectedMessage): void + /** @test */ + public function showsProperMessageWhenGeoLiteUpdateFails(): void { - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void { - }); - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($olderDbExists): void { - [$mustBeUpdated, $handleProgress] = $args; - - $mustBeUpdated($olderDbExists); - $handleProgress(100, 50); - - throw $olderDbExists - ? GeolocationDbUpdateFailedException::withOlderDb() - : GeolocationDbUpdateFailedException::withoutOlderDb(); - }, - ); + $this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString( - sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'), - $output, - ); - self::assertStringContainsString($expectedMessage, $output); - $locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists); - $checkDbUpdate->shouldHaveBeenCalledOnce(); - } - - public function provideParams(): iterable - { - yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.']; - yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.']; + self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output); + $this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index ee0793bc..fbb5ace9 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -6,17 +6,13 @@ namespace ShlinkioTest\Shlink\CLI\Factory; use Laminas\ServiceManager\ServiceManager; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\Core\Options\AppOptions; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; +use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; class ApplicationFactoryTest extends TestCase { - use ProphecyTrait; + use CliTestUtilsTrait; private ApplicationFactory $factory; @@ -54,17 +50,4 @@ class ApplicationFactoryTest extends TestCase AppOptions::class => new AppOptions(), ]]); } - - private function createCommandMock(string $name): ObjectProphecy - { - $command = $this->prophesize(Command::class); - $command->getName()->willReturn($name); - $command->getDefinition()->willReturn($name); - $command->isEnabled()->willReturn(true); - $command->getAliases()->willReturn([]); - $command->setApplication(Argument::type(Application::class))->willReturn(function (): void { - }); - - return $command; - } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 479b497a..7dfd5df2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -24,6 +24,7 @@ return [ Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class, Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, + Options\TrackingOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class, @@ -47,6 +48,7 @@ return [ Action\RedirectAction::class => ConfigAbstractFactory::class, Action\PixelAction::class => ConfigAbstractFactory::class, Action\QrCodeAction::class => ConfigAbstractFactory::class, + Action\RobotsAction::class => ConfigAbstractFactory::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class, @@ -56,6 +58,8 @@ return [ Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, + + Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, ], 'aliases' => [ @@ -75,6 +79,7 @@ return [ Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'], Options\UrlShortenerOptions::class => ['config.url_shortener'], + Options\TrackingOptions::class => ['config.tracking'], Service\UrlShortener::class => [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, @@ -85,7 +90,7 @@ return [ Visit\VisitsTracker::class => [ 'em', EventDispatcherInterface::class, - Options\UrlShortenerOptions::class, + Options\TrackingOptions::class, ], Service\ShortUrlService::class => [ 'em', @@ -112,14 +117,14 @@ return [ Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, - Options\AppOptions::class, + Options\TrackingOptions::class, Util\RedirectResponseHelper::class, 'Logger_Shlink', ], Action\PixelAction::class => [ Service\ShortUrl\ShortUrlResolver::class, Visit\VisitsTracker::class, - Options\AppOptions::class, + Options\TrackingOptions::class, 'Logger_Shlink', ], Action\QrCodeAction::class => [ @@ -127,6 +132,7 @@ return [ ShortUrl\Helper\ShortUrlStringifier::class, 'Logger_Shlink', ], + Action\RobotsAction::class => [Crawling\CrawlingHelper::class], ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], @@ -144,6 +150,8 @@ return [ Service\ShortUrl\ShortCodeHelper::class, Util\DoctrineBatchHelper::class, ], + + Crawling\CrawlingHelper::class => ['em'], ], ]; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php index 751e513c..a9269d36 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php @@ -95,4 +95,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('title_was_auto_resolved') ->option('default', false) ->build(); + + $builder->createField('crawlable', Types::BOOLEAN) + ->columnName('crawlable') + ->option('default', false) + ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index efcccb65..8886e141 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -65,4 +65,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('type') ->length(255) ->build(); + + $builder->createField('potentialBot', Types::BOOLEAN) + ->columnName('potential_bot') + ->option('default', false) + ->build(); }; diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 5c2c88e0..bddd59f5 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -7,21 +7,23 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use Symfony\Component\Mercure\Publisher; +use Symfony\Component\Mercure\Hub; return [ 'events' => [ 'regular' => [ - EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\NotifyVisitToMercure::class, - EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\Event\UrlVisited::class => [ + EventDispatcher\LocateVisit::class, ], ], 'async' => [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, + EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\NotifyVisitToMercure::class, + EventDispatcher\NotifyVisitToWebHooks::class, + EventDispatcher\UpdateGeoLiteDb::class, ], ], ], @@ -31,10 +33,14 @@ return [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, ], 'delegators' => [ - EventDispatcher\LocateVisit::class => [ + EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\NotifyVisitToWebHooks::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], ], @@ -45,7 +51,7 @@ return [ IpLocationResolverInterface::class, 'em', 'Logger_Shlink', - GeolocationDbUpdater::class, + DbUpdater::class, EventDispatcherInterface::class, ], EventDispatcher\NotifyVisitToWebHooks::class => [ @@ -57,11 +63,12 @@ return [ Options\AppOptions::class, ], EventDispatcher\NotifyVisitToMercure::class => [ - Publisher::class, + Hub::class, Mercure\MercureUpdatesGenerator::class, 'em', 'Logger_Shlink', ], + EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], ], ]; diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php index a95e8e96..c3f4b66a 100644 --- a/module/Core/config/routes.config.php +++ b/module/Core/config/routes.config.php @@ -9,6 +9,14 @@ use Shlinkio\Shlink\Core\Action; return [ 'routes' => [ + [ + 'name' => Action\RobotsAction::class, + 'path' => '/robots.txt', + 'middleware' => [ + Action\RobotsAction::class, + ], + 'allowed_methods' => [RequestMethod::METHOD_GET], + ], [ 'name' => Action\RedirectAction::class, 'path' => '/{shortCode}', diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 00954049..867f7c7d 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core; use Cake\Chronos\Chronos; use DateTimeInterface; use Fig\Http\Message\StatusCodeInterface; +use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; @@ -50,6 +51,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en $startDate = parseDateFromQuery($query, $startDateName); $endDate = parseDateFromQuery($query, $endDateName); + // TODO Use match expression when migrating to PHP8 if ($startDate === null && $endDate === null) { return DateRange::emptyInstance(); } @@ -127,3 +129,13 @@ function kebabCaseToCamelCase(string $name): string { return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $name)))); } + +function isCrawler(string $userAgent): bool +{ + static $detector; + if ($detector === null) { + $detector = new CrawlerDetect(); + } + + return $detector->isCrawler($userAgent); +} diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index b6a119b2..567e930c 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -18,7 +18,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Options\AppOptions; +use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; @@ -29,18 +29,18 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet { private ShortUrlResolverInterface $urlResolver; private VisitsTrackerInterface $visitTracker; - private AppOptions $appOptions; + private TrackingOptions $trackingOptions; private LoggerInterface $logger; public function __construct( ShortUrlResolverInterface $urlResolver, VisitsTrackerInterface $visitTracker, - AppOptions $appOptions, + TrackingOptions $trackingOptions, ?LoggerInterface $logger = null ) { $this->urlResolver = $urlResolver; $this->visitTracker = $visitTracker; - $this->appOptions = $appOptions; + $this->trackingOptions = $trackingOptions; $this->logger = $logger ?? new NullLogger(); } @@ -48,7 +48,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet { $identifier = ShortUrlIdentifier::fromRedirectRequest($request); $query = $request->getQueryParams(); - $disableTrackParam = $this->appOptions->getDisableTrackParam(); + $disableTrackParam = $this->trackingOptions->getDisableTrackParam(); try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 3209d651..1b2b5012 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; -use Endroid\QrCode\QrCode; +use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\Writer\SvgWriter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -50,16 +50,17 @@ class QrCodeAction implements MiddlewareInterface } $query = $request->getQueryParams(); - $qrCode = new QrCode($this->stringifier->stringify($shortUrl)); - $qrCode->setSize($this->resolveSize($request, $query)); - $qrCode->setMargin($this->resolveMargin($query)); + $qrCode = Builder::create() + ->data($this->stringifier->stringify($shortUrl)) + ->size($this->resolveSize($request, $query)) + ->margin($this->resolveMargin($query)); $format = $query['format'] ?? 'png'; if ($format === 'svg') { - $qrCode->setWriter(new SvgWriter()); + $qrCode->writer(new SvgWriter()); } - return new QrCodeResponse($qrCode); + return new QrCodeResponse($qrCode->build()); } private function resolveSize(Request $request, array $query): int diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index d346456b..7da67b59 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -21,11 +21,11 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa public function __construct( ShortUrlResolverInterface $urlResolver, VisitsTrackerInterface $visitTracker, - Options\AppOptions $appOptions, + Options\TrackingOptions $trackingOptions, RedirectResponseHelperInterface $redirectResponseHelper, ?LoggerInterface $logger = null ) { - parent::__construct($urlResolver, $visitTracker, $appOptions, $logger); + parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger); $this->redirectResponseHelper = $redirectResponseHelper; } diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php new file mode 100644 index 00000000..31539b92 --- /dev/null +++ b/module/Core/src/Action/RobotsAction.php @@ -0,0 +1,49 @@ +crawlingHelper = $crawlingHelper; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(self::STATUS_OK, ['Content-type' => 'text/plain'], $this->buildRobots()); + } + + private function buildRobots(): iterable + { + yield <<crawlingHelper->listCrawlableShortCodes(); + foreach ($shortCodes as $shortCode) { + yield sprintf('Allow: /%s%s', $shortCode, PHP_EOL); + } + + yield 'Disallow: /'; + } +} diff --git a/module/Core/src/Config/DeprecatedConfigParser.php b/module/Core/src/Config/DeprecatedConfigParser.php index 92074bfc..b3421146 100644 --- a/module/Core/src/Config/DeprecatedConfigParser.php +++ b/module/Core/src/Config/DeprecatedConfigParser.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Config; use function Functional\compose; +/** @deprecated */ class DeprecatedConfigParser { public function __invoke(array $config): array diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index b578799b..2b0b1d71 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -19,7 +19,7 @@ use function uksort; class SimplifiedConfigParser { private const SIMPLIFIED_CONFIG_MAPPING = [ - 'disable_track_param' => ['app_options', 'disable_track_param'], + 'disable_track_param' => ['tracking', 'disable_track_param'], 'short_domain_schema' => ['url_shortener', 'domain', 'schema'], 'short_domain_host' => ['url_shortener', 'domain', 'hostname'], 'validate_url' => ['url_shortener', 'validate_url'], @@ -38,7 +38,7 @@ class SimplifiedConfigParser 'mercure_public_hub_url' => ['mercure', 'public_hub_url'], 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'], 'mercure_jwt_secret' => ['mercure', 'jwt_secret'], - 'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'], + 'anonymize_remote_addr' => ['tracking', 'anonymize_remote_addr'], 'redirect_status_code' => ['url_shortener', 'redirect_status_code'], 'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'], 'port' => ['mezzio-swoole', 'swoole-http-server', 'port'], diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php new file mode 100644 index 00000000..5f688645 --- /dev/null +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -0,0 +1,26 @@ +em = $em; + } + + public function listCrawlableShortCodes(): iterable + { + /** @var ShortUrlRepositoryInterface $repo */ + $repo = $this->em->getRepository(ShortUrl::class); + yield from $repo->findCrawlableShortCodes(); + } +} diff --git a/module/Core/src/Crawling/CrawlingHelperInterface.php b/module/Core/src/Crawling/CrawlingHelperInterface.php new file mode 100644 index 00000000..635a4fc9 --- /dev/null +++ b/module/Core/src/Crawling/CrawlingHelperInterface.php @@ -0,0 +1,13 @@ +authorApiKey = $meta->getApiKey(); $instance->title = $meta->getTitle(); $instance->titleWasAutoResolved = $meta->titleWasAutoResolved(); + $instance->crawlable = $meta->isCrawlable(); return $instance; } @@ -86,17 +90,29 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null ): self { $meta = [ + ShortUrlInputFilter::VALIDATE_URL => false, ShortUrlInputFilter::LONG_URL => $url->longUrl(), ShortUrlInputFilter::DOMAIN => $url->domain(), ShortUrlInputFilter::TAGS => $url->tags(), ShortUrlInputFilter::TITLE => $url->title(), - ShortUrlInputFilter::VALIDATE_URL => false, + ShortUrlInputFilter::MAX_VISITS => $url->meta()->maxVisits(), ]; if ($importShortCode) { $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode(); } $instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver); + + $validSince = $url->meta()->validSince(); + if ($validSince !== null) { + $instance->validSince = Chronos::instance($validSince); + } + + $validUntil = $url->meta()->validUntil(); + if ($validUntil !== null) { + $instance->validUntil = Chronos::instance($validUntil); + } + $instance->importSource = $url->source(); $instance->importOriginalShortCode = $url->shortCode(); $instance->dateCreated = Chronos::instance($url->createdAt()); @@ -132,6 +148,11 @@ class ShortUrl extends AbstractEntity return $this->tags; } + public function authorApiKey(): ?ApiKey + { + return $this->authorApiKey; + } + public function getValidSince(): ?Chronos { return $this->validSince; @@ -147,6 +168,20 @@ class ShortUrl extends AbstractEntity return count($this->visits); } + public function mostRecentImportedVisitDate(): ?Chronos + { + /** @var Selectable $visits */ + $visits = $this->visits; + $criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED)) + ->orderBy(['id' => 'DESC']) + ->setMaxResults(1); + + /** @var Visit|false $visit */ + $visit = $visits->matching($criteria)->last(); + + return $visit === false ? null : $visit->getDate(); + } + /** * @param Collection|Visit[] $visits * @internal @@ -162,11 +197,16 @@ class ShortUrl extends AbstractEntity return $this->maxVisits; } - public function getTitle(): ?string + public function title(): ?string { return $this->title; } + public function crawlable(): bool + { + return $this->crawlable; + } + public function update( ShortUrlEdit $shortUrlEdit, ?ShortUrlRelationResolverInterface $relationResolver = null @@ -187,6 +227,9 @@ class ShortUrl extends AbstractEntity $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); } + if ($shortUrlEdit->crawlableWasProvided()) { + $this->crawlable = $shortUrlEdit->crawlable(); + } if ( $this->title === null || $shortUrlEdit->titleWasProvided() diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index 61739dec..358bedde 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -11,32 +11,88 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; + +use function Shlinkio\Shlink\Core\isCrawler; class Visit extends AbstractEntity implements JsonSerializable { public const TYPE_VALID_SHORT_URL = 'valid_short_url'; + public const TYPE_IMPORTED = 'imported'; public const TYPE_INVALID_SHORT_URL = 'invalid_short_url'; public const TYPE_BASE_URL = 'base_url'; public const TYPE_REGULAR_404 = 'regular_404'; private string $referer; private Chronos $date; - private ?string $remoteAddr; - private ?string $visitedUrl; + private ?string $remoteAddr = null; + private ?string $visitedUrl = null; private string $userAgent; private string $type; private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; + private bool $potentialBot; - private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true) + private function __construct(?ShortUrl $shortUrl, string $type) { $this->shortUrl = $shortUrl; $this->date = Chronos::now(); + $this->type = $type; + } + + public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self + { + $instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL); + $instance->hydrateFromVisitor($visitor, $anonymize); + + return $instance; + } + + public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self + { + $instance = new self($shortUrl, self::TYPE_IMPORTED); + $instance->userAgent = $importedVisit->userAgent(); + $instance->potentialBot = isCrawler($instance->userAgent); + $instance->referer = $importedVisit->referer(); + $instance->date = Chronos::instance($importedVisit->date()); + + $importedLocation = $importedVisit->location(); + $instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null; + + return $instance; + } + + public static function forBasePath(Visitor $visitor, bool $anonymize = true): self + { + $instance = new self(null, self::TYPE_BASE_URL); + $instance->hydrateFromVisitor($visitor, $anonymize); + + return $instance; + } + + public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self + { + $instance = new self(null, self::TYPE_INVALID_SHORT_URL); + $instance->hydrateFromVisitor($visitor, $anonymize); + + return $instance; + } + + public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self + { + $instance = new self(null, self::TYPE_REGULAR_404); + $instance->hydrateFromVisitor($visitor, $anonymize); + + return $instance; + } + + private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void + { $this->userAgent = $visitor->getUserAgent(); $this->referer = $visitor->getReferer(); $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); $this->visitedUrl = $visitor->getVisitedUrl(); - $this->type = $type; + $this->potentialBot = $visitor->isPotentialBot(); } private function processAddress(bool $anonymize, ?string $address): ?string @@ -53,26 +109,6 @@ class Visit extends AbstractEntity implements JsonSerializable } } - public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self - { - return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize); - } - - public static function forBasePath(Visitor $visitor, bool $anonymize = true): self - { - return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize); - } - - public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self - { - return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize); - } - - public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self - { - return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize); - } - public function getRemoteAddr(): ?string { return $this->remoteAddr; @@ -119,6 +155,15 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->type; } + /** + * Needed only for ArrayCollections to be able to apply criteria filtering + * @internal + */ + public function getType(): string + { + return $this->type(); + } + public function jsonSerialize(): array { return [ @@ -126,6 +171,7 @@ class Visit extends AbstractEntity implements JsonSerializable 'date' => $this->date->toAtomString(), 'userAgent' => $this->userAgent, 'visitLocation' => $this->visitLocation, + 'potentialBot' => $this->potentialBot, ]; } diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php index ef545bba..594126a7 100644 --- a/module/Core/src/Entity/VisitLocation.php +++ b/module/Core/src/Entity/VisitLocation.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Entity; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocation extends AbstractEntity implements VisitLocationInterface @@ -19,9 +20,53 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface private string $timezone; private bool $isEmpty; - public function __construct(Location $location) + private function __construct() { - $this->exchangeLocationInfo($location); + } + + public static function fromGeolocation(Location $location): self + { + $instance = new self(); + + $instance->countryCode = $location->countryCode(); + $instance->countryName = $location->countryName(); + $instance->regionName = $location->regionName(); + $instance->cityName = $location->city(); + $instance->latitude = $location->latitude(); + $instance->longitude = $location->longitude(); + $instance->timezone = $location->timeZone(); + $instance->computeIsEmpty(); + + return $instance; + } + + public static function fromImport(ImportedShlinkVisitLocation $location): self + { + $instance = new self(); + + $instance->countryCode = $location->countryCode(); + $instance->countryName = $location->countryName(); + $instance->regionName = $location->regionName(); + $instance->cityName = $location->cityName(); + $instance->latitude = $location->latitude(); + $instance->longitude = $location->longitude(); + $instance->timezone = $location->timeZone(); + $instance->computeIsEmpty(); + + return $instance; + } + + private function computeIsEmpty(): void + { + $this->isEmpty = ( + $this->countryCode === '' && + $this->countryName === '' && + $this->regionName === '' && + $this->cityName === '' && + $this->latitude === 0.0 && + $this->longitude === 0.0 && + $this->timezone === '' + ); } public function getCountryName(): string @@ -49,26 +94,6 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface return $this->isEmpty; } - private function exchangeLocationInfo(Location $info): void - { - $this->countryCode = $info->countryCode(); - $this->countryName = $info->countryName(); - $this->regionName = $info->regionName(); - $this->cityName = $info->city(); - $this->latitude = $info->latitude(); - $this->longitude = $info->longitude(); - $this->timezone = $info->timeZone(); - $this->isEmpty = ( - $this->countryCode === '' && - $this->countryName === '' && - $this->regionName === '' && - $this->cityName === '' && - $this->latitude === 0.0 && - $this->longitude === 0.0 && - $this->timezone === '' - ); - } - public function jsonSerialize(): array { return [ diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index 32da6060..0150c529 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -7,31 +7,29 @@ namespace Shlinkio\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; - -use function sprintf; +use Throwable; class LocateVisit { private IpLocationResolverInterface $ipLocationResolver; private EntityManagerInterface $em; private LoggerInterface $logger; - private GeolocationDbUpdaterInterface $dbUpdater; + private DbUpdaterInterface $dbUpdater; private EventDispatcherInterface $eventDispatcher; public function __construct( IpLocationResolverInterface $ipLocationResolver, EntityManagerInterface $em, LoggerInterface $logger, - GeolocationDbUpdaterInterface $dbUpdater, + DbUpdaterInterface $dbUpdater, EventDispatcherInterface $eventDispatcher ) { $this->ipLocationResolver = $ipLocationResolver; @@ -54,49 +52,37 @@ class LocateVisit return; } - if ($this->downloadOrUpdateGeoLiteDb($visitId)) { - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); - } - + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } - private function downloadOrUpdateGeoLiteDb(string $visitId): bool - { - try { - $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists): void { - $this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading')); - }); - } catch (GeolocationDbUpdateFailedException $e) { - if (! $e->olderDbExists()) { - $this->logger->error( - 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', - ['e' => $e, 'visitId' => $visitId], - ); - return false; - } - - $this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]); - } - - return true; - } - private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void { + if (! $this->dbUpdater->databaseFileExists()) { + $this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [ + 'visitId' => $visitId, + ]); + return; + } + $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); $addr = $originalIpAddress ?? $visit->getRemoteAddr(); try { $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); - $visit->locate(new VisitLocation($location)); + $visit->locate(VisitLocation::fromGeolocation($location)); $this->em->flush(); } catch (WrongIpException $e) { $this->logger->warning( 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', ['e' => $e, 'visitId' => $visitId], ); + } catch (Throwable $e) { + $this->logger->error( + 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', + ['e' => $e, 'visitId' => $visitId], + ); } } } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php index 0cf438ed..33adf965 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToMercure.php @@ -9,7 +9,7 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; -use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Throwable; @@ -17,18 +17,18 @@ use function Functional\each; class NotifyVisitToMercure { - private PublisherInterface $publisher; + private HubInterface $hub; private MercureUpdatesGeneratorInterface $updatesGenerator; private EntityManagerInterface $em; private LoggerInterface $logger; public function __construct( - PublisherInterface $publisher, + HubInterface $hub, MercureUpdatesGeneratorInterface $updatesGenerator, EntityManagerInterface $em, LoggerInterface $logger ) { - $this->publisher = $publisher; + $this->hub = $hub; $this->em = $em; $this->logger = $logger; $this->updatesGenerator = $updatesGenerator; @@ -48,7 +48,7 @@ class NotifyVisitToMercure } try { - each($this->determineUpdatesForVisit($visit), fn (Update $update) => ($this->publisher)($update)); + each($this->determineUpdatesForVisit($visit), fn (Update $update) => $this->hub->publish($update)); } catch (Throwable $e) { $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ 'e' => $e, diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php new file mode 100644 index 00000000..f17a7ffb --- /dev/null +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -0,0 +1,45 @@ +dbUpdater = $dbUpdater; + $this->logger = $logger; + } + + public function __invoke(): void + { + $beforeDownload = fn (bool $olderDbExists) => $this->logger->notice( + sprintf('%s GeoLite2 db file...', $olderDbExists ? 'Updating' : 'Downloading'), + ); + $messageLogged = false; + $handleProgress = function (int $total, int $downloaded, bool $olderDbExists) use (&$messageLogged): void { + if ($messageLogged || $total > $downloaded) { + return; + } + + $messageLogged = true; + $this->logger->notice(sprintf('Finished %s GeoLite2 db file', $olderDbExists ? 'updating' : 'downloading')); + }; + + try { + $this->dbUpdater->checkDbUpdate($beforeDownload, $handleProgress); + } catch (Throwable $e) { + $this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]); + } + } +} diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 8887f961..3b8b0b15 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function sprintf; @@ -34,4 +35,9 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem return $e; } + + public static function fromImport(ImportedShlinkUrl $importedUrl): self + { + return self::fromSlug($importedUrl->shortCode(), $importedUrl->domain()); + } } diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 2b5cde17..e700e8a8 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -6,12 +6,14 @@ namespace Shlinkio\Shlink\Core\Importer; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; 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\Sources\ImportSources; use Symfony\Component\Console\Style\StyleInterface; use function sprintf; @@ -22,6 +24,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private ShortUrlRelationResolverInterface $relationResolver; private ShortCodeHelperInterface $shortCodeHelper; private DoctrineBatchHelperInterface $batchHelper; + private ShortUrlRepositoryInterface $shortUrlRepo; public function __construct( EntityManagerInterface $em, @@ -33,6 +36,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface $this->relationResolver = $relationResolver; $this->shortCodeHelper = $shortCodeHelper; $this->batchHelper = $batchHelper; + $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); // @phpstan-ignore-line } /** @@ -40,51 +44,65 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface */ public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void { - /** @var ShortUrlRepositoryInterface $shortUrlRepo */ - $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $importShortCodes = $params['import_short_codes']; - $iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100); + $source = $params['source']; + $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100); - /** @var ImportedShlinkUrl $url */ - foreach ($iterable as $url) { - $longUrl = $url->longUrl(); + /** @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); - // Skip already imported URLs - if ($shortUrlRepo->importedUrlExists($url)) { - $io->text(sprintf('%s: Skipped', $longUrl)); + return $action === 'Skip'; + }; + $longUrl = $importedUrl->longUrl(); + + try { + $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); + } catch (NonUniqueSlugException $e) { + $io->text(sprintf('%s: Error', $longUrl)); continue; } - $shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver); - if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) { - continue; - } - - $this->em->persist($shortUrl); - $io->text(sprintf('%s: Imported', $longUrl)); + $resultMessage = $shortUrlImporting->importVisits($importedUrl->visits(), $this->em); + $io->text(sprintf('%s: %s', $longUrl, $resultMessage)); } } + private function resolveShortUrl( + ImportedShlinkUrl $importedUrl, + bool $importShortCodes, + callable $skipOnShortCodeConflict + ): ShortUrlImporting { + $alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl); + if ($alreadyImportedShortUrl !== null) { + return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl); + } + + $shortUrl = ShortUrl::fromImport($importedUrl, $importShortCodes, $this->relationResolver); + if (! $this->handleShortCodeUniqueness($shortUrl, $importShortCodes, $skipOnShortCodeConflict)) { + throw NonUniqueSlugException::fromImport($importedUrl); + } + + $this->em->persist($shortUrl); + return ShortUrlImporting::fromNewShortUrl($shortUrl); + } + private function handleShortCodeUniqueness( - ImportedShlinkUrl $url, ShortUrl $shortUrl, - StyleInterface $io, - bool $importShortCodes + bool $importShortCodes, + callable $skipOnShortCodeConflict ): bool { if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) { return true; } - $longUrl = $url->longUrl(); - $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?', - $longUrl, - $url->shortCode(), - ), ['Generate new short-code', 'Skip'], 1); - - if ($action === 'Skip') { - $io->text(sprintf('%s: Skipped', $longUrl)); + if ($skipOnShortCodeConflict()) { return false; } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php new file mode 100644 index 00000000..b5ae4651 --- /dev/null +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -0,0 +1,65 @@ +shortUrl = $shortUrl; + $this->isNew = $isNew; + } + + public static function fromExistingShortUrl(ShortUrl $shortUrl): self + { + return new self($shortUrl, false); + } + + public static function fromNewShortUrl(ShortUrl $shortUrl): self + { + return new self($shortUrl, true); + } + + /** + * @param iterable|ImportedShlinkVisit[] $visits + */ + public function importVisits(iterable $visits, EntityManagerInterface $em): string + { + $mostRecentImportedDate = $this->shortUrl->mostRecentImportedVisitDate(); + + $importedVisits = 0; + foreach ($visits as $importedVisit) { + // Skip visits which are older than the most recent already imported visit's date + if ( + $mostRecentImportedDate !== null + && $mostRecentImportedDate->gte(Chronos::instance($importedVisit->date())) + ) { + continue; + } + + $em->persist(Visit::fromImport($this->shortUrl, $importedVisit)); + $importedVisits++; + } + + if ($importedVisits === 0) { + return $this->isNew ? 'Imported' : 'Skipped'; + } + + return $this->isNew + ? sprintf('Imported with %s visits', $importedVisits) + : sprintf('Skipped. Imported %s visits', $importedVisits); + } +} diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index 3327aad4..32c1ca1e 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -30,6 +30,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface private ?string $title = null; private bool $titleWasAutoResolved = false; private ?bool $validateUrl = null; + private bool $crawlablePropWasProvided = false; + private bool $crawlable = false; private function __construct() { @@ -61,6 +63,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); $this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data); + $this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); @@ -69,6 +72,7 @@ final class ShortUrlEdit implements TitleResolutionModelInterface $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL); $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); + $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); } public function longUrl(): ?string @@ -162,4 +166,14 @@ final class ShortUrlEdit implements TitleResolutionModelInterface { return $this->validateUrl; } + + public function crawlable(): bool + { + return $this->crawlable; + } + + public function crawlableWasProvided(): bool + { + return $this->crawlablePropWasProvided; + } } diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index 4a74ba07..a277782c 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Model; use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Symfony\Component\Console\Input\InputInterface; final class ShortUrlIdentifier @@ -42,6 +43,19 @@ final class ShortUrlIdentifier return new self($shortCode, $domain); } + public static function fromShortUrl(ShortUrl $shortUrl): self + { + $domain = $shortUrl->getDomain(); + $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + + return new self($shortUrl->getShortCode(), $domainAuthority); + } + + public static function fromShortCodeAndDomain(string $shortCode, ?string $domain = null): self + { + return new self($shortCode, $domain); + } + public function shortCode(): string { return $this->shortCode; diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index df25735c..06e0eee7 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -31,6 +31,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface private array $tags = []; private ?string $title = null; private bool $titleWasAutoResolved = false; + private bool $crawlable = false; private function __construct() { @@ -80,6 +81,7 @@ final class ShortUrlMeta implements TitleResolutionModelInterface $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); + $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); } public function getLongUrl(): string @@ -188,4 +190,9 @@ final class ShortUrlMeta implements TitleResolutionModelInterface return $copy; } + + public function isCrawlable(): bool + { + return $this->crawlable; + } } diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index 7438bdce..b73ed68a 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Core\Model; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; +use Shlinkio\Shlink\Core\Options\TrackingOptions; +use function Shlinkio\Shlink\Core\isCrawler; use function substr; final class Visitor @@ -20,6 +22,7 @@ final class Visitor private string $referer; private string $visitedUrl; private ?string $remoteAddress; + private bool $potentialBot; public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) { @@ -27,6 +30,7 @@ final class Visitor $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH); $this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH); + $this->potentialBot = isCrawler($userAgent); } private function cropToLength(?string $value, int $length): ?string @@ -49,6 +53,11 @@ final class Visitor return new self('', '', null, ''); } + public static function botInstance(): self + { + return new self('cf-facebook', '', null, ''); + } + public function getUserAgent(): string { return $this->userAgent; @@ -68,4 +77,24 @@ final class Visitor { return $this->visitedUrl; } + + public function isPotentialBot(): bool + { + return $this->potentialBot; + } + + public function normalizeForTrackingOptions(TrackingOptions $options): self + { + $instance = new self( + $options->disableUaTracking() ? '' : $this->userAgent, + $options->disableReferrerTracking() ? '' : $this->referer, + $options->disableIpTracking() ? null : $this->remoteAddress, + $this->visitedUrl, + ); + + // Keep the fact that the visit was a potential bot, even if we no longer save the user agent + $instance->potentialBot = $this->potentialBot; + + return $instance; + } } diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index b579239b..f8498c7a 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -16,12 +16,18 @@ final class VisitsParams private ?DateRange $dateRange; private int $page; private int $itemsPerPage; + private bool $excludeBots; - public function __construct(?DateRange $dateRange = null, int $page = self::FIRST_PAGE, ?int $itemsPerPage = null) - { + public function __construct( + ?DateRange $dateRange = null, + int $page = self::FIRST_PAGE, + ?int $itemsPerPage = null, + bool $excludeBots = false + ) { $this->dateRange = $dateRange ?? new DateRange(); $this->page = $page; $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); + $this->excludeBots = $excludeBots; } private function determineItemsPerPage(?int $itemsPerPage): int @@ -39,6 +45,7 @@ final class VisitsParams parseDateRangeFromQuery($query, 'startDate', 'endDate'), (int) ($query['page'] ?? 1), isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, + isset($query['excludeBots']), ); } @@ -56,4 +63,9 @@ final class VisitsParams { return $this->itemsPerPage; } + + public function excludeBots(): bool + { + return $this->excludeBots; + } } diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index 66d76126..8fde2663 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -12,7 +12,6 @@ class AppOptions extends AbstractOptions { private string $name = ''; private string $version = '1.0'; - private ?string $disableTrackParam = null; public function getName(): string { @@ -36,16 +35,10 @@ class AppOptions extends AbstractOptions return $this; } - /** - */ - public function getDisableTrackParam(): ?string - { - return $this->disableTrackParam; - } - + /** @deprecated */ protected function setDisableTrackParam(?string $disableTrackParam): self { - $this->disableTrackParam = $disableTrackParam; + // Keep just for backwards compatibility during hydration return $this; } diff --git a/module/Core/src/Options/TrackingOptions.php b/module/Core/src/Options/TrackingOptions.php new file mode 100644 index 00000000..98e09085 --- /dev/null +++ b/module/Core/src/Options/TrackingOptions.php @@ -0,0 +1,88 @@ +anonymizeRemoteAddr; + } + + protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void + { + $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; + } + + public function trackOrphanVisits(): bool + { + return $this->trackOrphanVisits; + } + + protected function setTrackOrphanVisits(bool $trackOrphanVisits): void + { + $this->trackOrphanVisits = $trackOrphanVisits; + } + + public function getDisableTrackParam(): ?string + { + return $this->disableTrackParam; + } + + protected function setDisableTrackParam(?string $disableTrackParam): void + { + $this->disableTrackParam = $disableTrackParam; + } + + public function disableTracking(): bool + { + return $this->disableTracking; + } + + protected function setDisableTracking(bool $disableTracking): void + { + $this->disableTracking = $disableTracking; + } + + public function disableIpTracking(): bool + { + return $this->disableIpTracking; + } + + protected function setDisableIpTracking(bool $disableIpTracking): void + { + $this->disableIpTracking = $disableIpTracking; + } + + public function disableReferrerTracking(): bool + { + return $this->disableReferrerTracking; + } + + protected function setDisableReferrerTracking(bool $disableReferrerTracking): void + { + $this->disableReferrerTracking = $disableReferrerTracking; + } + + public function disableUaTracking(): bool + { + return $this->disableUaTracking; + } + + protected function setDisableUaTracking(bool $disableUaTracking): void + { + $this->disableUaTracking = $disableUaTracking; + } +} diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index e1956203..a0005da2 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -19,8 +19,6 @@ class UrlShortenerOptions extends AbstractOptions private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private bool $autoResolveTitles = false; - private bool $anonymizeRemoteAddr = true; - private bool $trackOrphanVisits = true; public function isUrlValidationEnabled(): bool { @@ -69,23 +67,15 @@ class UrlShortenerOptions extends AbstractOptions $this->autoResolveTitles = $autoResolveTitles; } - public function anonymizeRemoteAddr(): bool - { - return $this->anonymizeRemoteAddr; - } - + /** @deprecated */ protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void { - $this->anonymizeRemoteAddr = $anonymizeRemoteAddr; - } - - public function trackOrphanVisits(): bool - { - return $this->trackOrphanVisits; + // Keep just for backwards compatibility during hydration } + /** @deprecated */ protected function setTrackOrphanVisits(bool $trackOrphanVisits): void { - $this->trackOrphanVisits = $trackOrphanVisits; + // Keep just for backwards compatibility during hydration } } diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 7167b9e7..d7361fb3 100644 --- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { @@ -20,11 +22,20 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte protected function doCount(): int { - return $this->repo->countOrphanVisits($this->params->getDateRange()); + return $this->repo->countOrphanVisits(new VisitsCountFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + )); } public function getSlice($offset, $length): iterable // phpcs:ignore { - return $this->repo->findOrphanVisits($this->params->getDateRange(), $length, $offset); + return $this->repo->findOrphanVisits(new VisitsListFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + null, + $length, + $offset, + )); } } diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php index 4c4e718b..d7c0580f 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php @@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\Paginator\Adapter; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter @@ -32,10 +34,13 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte { return $this->visitRepository->findVisitsByTag( $this->tag, - $this->params->getDateRange(), - $length, - $offset, - $this->resolveSpec(), + new VisitsListFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->resolveSpec(), + $length, + $offset, + ), ); } @@ -43,8 +48,11 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte { return $this->visitRepository->countVisitsByTag( $this->tag, - $this->params->getDateRange(), - $this->resolveSpec(), + new VisitsCountFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->resolveSpec(), + ), ); } diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php index 02ba37b3..d651b1b5 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php @@ -8,6 +8,8 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { @@ -31,22 +33,26 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter public function getSlice($offset, $length): array // phpcs:ignore { return $this->visitRepository->findVisitsByShortCode( - $this->identifier->shortCode(), - $this->identifier->domain(), - $this->params->getDateRange(), - $length, - $offset, - $this->spec, + $this->identifier, + new VisitsListFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->spec, + $length, + $offset, + ), ); } protected function doCount(): int { return $this->visitRepository->countVisitsByShortCode( - $this->identifier->shortCode(), - $this->identifier->domain(), - $this->params->getDateRange(), - $this->spec, + $this->identifier, + new VisitsCountFiltering( + $this->params->getDateRange(), + $this->params->excludeBots(), + $this->spec, + ), ); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index f7a089b7..c658d478 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -4,13 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; +use Doctrine\DBAL\LockMode; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; -use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -172,32 +174,44 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $query->getOneOrNullResult(); } - public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl + public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl { - $qb = $this->createFindOneQueryBuilder($shortCode, $domain, $spec); + $qb = $this->createFindOneQueryBuilder($identifier, $spec); $qb->select('s'); return $qb->getQuery()->getOneOrNullResult(); } - public function shortCodeIsInUse(string $slug, ?string $domain = null, ?Specification $spec = null): bool + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool { - $qb = $this->createFindOneQueryBuilder($slug, $domain, $spec); - $qb->select('COUNT(DISTINCT s.id)'); - - return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; + return $this->doShortCodeIsInUse($identifier, $spec, null); } - private function createFindOneQueryBuilder(string $slug, ?string $domain, ?Specification $spec): QueryBuilder + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool + { + return $this->doShortCodeIsInUse($identifier, $spec, LockMode::PESSIMISTIC_WRITE); + } + + private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool + { + $qb = $this->createFindOneQueryBuilder($identifier, $spec); + $qb->select('s.id'); + + $query = $qb->getQuery()->setLockMode($lockMode); + + return $query->getOneOrNullResult() !== null; + } + + private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, ?Specification $spec): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') ->where($qb->expr()->isNotNull('s.shortCode')) ->andWhere($qb->expr()->eq('s.shortCode', ':slug')) - ->setParameter('slug', $slug) + ->setParameter('slug', $identifier->shortCode()) ->setMaxResults(1); - $this->whereDomainIs($qb, $domain); + $this->whereDomainIs($qb, $identifier->domain()); $this->applySpecification($qb, $spec, 's'); @@ -264,12 +278,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function importedUrlExists(ImportedShlinkUrl $url): bool + public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('COUNT(DISTINCT s.id)') - ->from(ShortUrl::class, 's') - ->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) + $qb = $this->createQueryBuilder('s'); + $qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) ->setParameter('shortCode', $url->shortCode()) ->andWhere($qb->expr()->eq('s.importSource', ':importSource')) ->setParameter('importSource', $url->source()) @@ -277,8 +289,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $this->whereDomainIs($qb, $url->domain()); - $result = (int) $qb->getQuery()->getSingleScalarResult(); - return $result > 0; + return $qb->getQuery()->getOneOrNullResult(); } private function whereDomainIs(QueryBuilder $qb, ?string $domain): void @@ -291,4 +302,28 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $qb->andWhere($qb->expr()->isNull('s.domain')); } } + + public function findCrawlableShortCodes(): iterable + { + $blockSize = 1000; + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('DISTINCT s.shortCode') + ->from(ShortUrl::class, 's') + ->where($qb->expr()->eq('s.crawlable', ':crawlable')) + ->setParameter('crawlable', true) + ->setMaxResults($blockSize); + + $page = 0; + do { + $qbClone = (clone $qb)->setFirstResult($blockSize * $page); + $iterator = $qbClone->getQuery()->toIterable(); + $resultsFound = false; + $page++; + + foreach ($iterator as ['shortCode' => $shortCode]) { + $resultsFound = true; + yield $shortCode; + } + } while ($resultsFound); + } } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index e5662e20..7489f2a0 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -5,10 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; -use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -34,11 +35,15 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; - public function findOne(string $shortCode, ?string $domain = null, ?Specification $spec = null): ?ShortUrl; + public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; - public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool; + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; + + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl; - public function importedUrlExists(ImportedShlinkUrl $url): bool; + public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; + + public function findCrawlableShortCodes(): iterable; } diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index dd15c292..d21122d0 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Happyr\DoctrineSpecification\EntitySpecificationRepository; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; -use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; @@ -33,7 +32,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito /** * @return TagInfo[] */ - public function findTagsWithInfo(?Specification $spec = null): array + public function findTagsWithInfo(?ApiKey $apiKey = null): array { $qb = $this->createQueryBuilder('t'); $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount') @@ -42,7 +41,9 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->groupBy('t') ->orderBy('t.name', 'ASC'); - $this->applySpecification($qb, $spec, 't'); + if ($apiKey !== null) { + $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); + } $query = $qb->getQuery(); diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 86898ed1..924706ff 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -5,8 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; -use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; -use Happyr\DoctrineSpecification\Specification\Specification; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -17,7 +16,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe /** * @return TagInfo[] */ - public function findTagsWithInfo(?Specification $spec = null): array; + public function findTagsWithInfo(?ApiKey $apiKey = null): array; public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index b869093e..61cd108e 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -6,12 +6,14 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; -use Happyr\DoctrineSpecification\EntitySpecificationRepository; -use Happyr\DoctrineSpecification\Specification\Specification; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits; use Shlinkio\Shlink\Core\Visit\Spec\CountOfShortUrlVisits; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -66,11 +68,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo do { $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId)); - $iterator = $qb->getQuery()->iterate(); + $iterator = $qb->getQuery()->toIterable(); $resultsFound = false; /** @var Visit $visit */ - foreach ($iterator as $key => [$visit]) { + foreach ($iterator as $key => $visit) { $resultsFound = true; yield $key => $visit; } @@ -83,39 +85,27 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo /** * @return Visit[] */ - public function findVisitsByShortCode( - string $shortCode, - ?string $domain = null, - ?DateRange $dateRange = null, - ?int $limit = null, - ?int $offset = null, - ?Specification $spec = null - ): array { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); - return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); + public function findVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsListFiltering $filtering): array + { + $qb = $this->createVisitsByShortCodeQueryBuilder($identifier, $filtering); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } - public function countVisitsByShortCode( - string $shortCode, - ?string $domain = null, - ?DateRange $dateRange = null, - ?Specification $spec = null - ): int { - $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange, $spec); + public function countVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering): int + { + $qb = $this->createVisitsByShortCodeQueryBuilder($identifier, $filtering); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); } private function createVisitsByShortCodeQueryBuilder( - string $shortCode, - ?string $domain, - ?DateRange $dateRange, - ?Specification $spec = null + ShortUrlIdentifier $identifier, + VisitsCountFiltering $filtering ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($shortCode, $domain, $spec); + $shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec()); $shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1; // Parameters in this query need to be part of the query itself, as we need to use it a sub-query later @@ -124,36 +114,32 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->where($qb->expr()->eq('v.shortUrl', $shortUrlId)); + if ($filtering->excludeBots()) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } + // Apply date range filtering - $this->applyDatesInline($qb, $dateRange); + $this->applyDatesInline($qb, $filtering->dateRange()); return $qb; } - public function findVisitsByTag( - string $tag, - ?DateRange $dateRange = null, - ?int $limit = null, - ?int $offset = null, - ?Specification $spec = null - ): array { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); - return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); + public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array + { + $qb = $this->createVisitsByTagQueryBuilder($tag, $filtering); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int + public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int { - $qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange, $spec); + $qb = $this->createVisitsByTagQueryBuilder($tag, $filtering); $qb->select('COUNT(v.id)'); return (int) $qb->getQuery()->getSingleScalarResult(); } - private function createVisitsByTagQueryBuilder( - string $tag, - ?DateRange $dateRange, - ?Specification $spec - ): QueryBuilder { + private function createVisitsByTagQueryBuilder(string $tag, VisitsCountFiltering $filtering): QueryBuilder + { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not strictly provided by the caller, it's reasonably safe $qb = $this->getEntityManager()->createQueryBuilder(); @@ -162,13 +148,17 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ->join('s.tags', 't') ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound - $this->applyDatesInline($qb, $dateRange); - $this->applySpecification($qb, $spec, 'v'); + if ($filtering->excludeBots()) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } + + $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applySpecification($qb, $filtering->spec(), 'v'); return $qb; } - public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array + public function findOrphanVisits(VisitsListFiltering $filtering): array { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not strictly provided by the caller, it's reasonably safe @@ -176,14 +166,18 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->where($qb->expr()->isNull('v.shortUrl')); - $this->applyDatesInline($qb, $dateRange); + if ($filtering->excludeBots()) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } - return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset); + $this->applyDatesInline($qb, $filtering->dateRange()); + + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } - public function countOrphanVisits(?DateRange $dateRange = null): int + public function countOrphanVisits(VisitsCountFiltering $filtering): int { - return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($dateRange)); + return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering)); } public function countVisits(?ApiKey $apiKey = null): int @@ -203,6 +197,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array { + // TODO Order by date and ID, not just by ID (order by date DESC, id DESC). + // That ensures imported visits are properly ordered even if inserted in wrong chronological order. + $qb->select('v.id') ->orderBy('v.id', 'DESC') // Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 3ecf0bca..28f1e9a8 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -5,10 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; -use Happyr\DoctrineSpecification\EntitySpecificationRepositoryInterface; -use Happyr\DoctrineSpecification\Specification\Specification; -use Shlinkio\Shlink\Common\Util\DateRange; +use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface @@ -33,41 +34,23 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification /** * @return Visit[] */ - public function findVisitsByShortCode( - string $shortCode, - ?string $domain = null, - ?DateRange $dateRange = null, - ?int $limit = null, - ?int $offset = null, - ?Specification $spec = null - ): array; + public function findVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsListFiltering $filtering): array; - public function countVisitsByShortCode( - string $shortCode, - ?string $domain = null, - ?DateRange $dateRange = null, - ?Specification $spec = null - ): int; + public function countVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering): int; /** * @return Visit[] */ - public function findVisitsByTag( - string $tag, - ?DateRange $dateRange = null, - ?int $limit = null, - ?int $offset = null, - ?Specification $spec = null - ): array; + public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array; - public function countVisitsByTag(string $tag, ?DateRange $dateRange = null, ?Specification $spec = null): int; + public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int; /** * @return Visit[] */ - public function findOrphanVisits(?DateRange $dateRange = null, ?int $limit = null, ?int $offset = null): array; + public function findOrphanVisits(VisitsListFiltering $filtering): array; - public function countOrphanVisits(?DateRange $dateRange = null): int; + public function countOrphanVisits(VisitsCountFiltering $filtering): int; public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php index 6e4e57ac..83c3397e 100644 --- a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php @@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; -class ShortCodeHelper implements ShortCodeHelperInterface +class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper { private EntityManagerInterface $em; @@ -19,13 +20,9 @@ class ShortCodeHelper implements ShortCodeHelperInterface public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool { - $shortCode = $shortUrlToBeCreated->getShortCode(); - $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain !== null ? $domain->getAuthority() : null; - /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); - $otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domainAuthority); + $otherShortUrlsExist = $repo->shortCodeIsInUseWithLock(ShortUrlIdentifier::fromShortUrl($shortUrlToBeCreated)); if (! $otherShortUrlsExist) { return true; diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php b/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php index af3f2aa5..a020a30c 100644 --- a/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl; -interface ShortCodeHelperInterface +interface ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelperInterface { public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool; } diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php index 6e03114c..1394e1ab 100644 --- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php @@ -27,11 +27,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne( - $identifier->shortCode(), - $identifier->domain(), - $apiKey !== null ? $apiKey->spec() : null, - ); + $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index fd0428bf..8601a045 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -7,18 +7,26 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\Common\Collections; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Events; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Tag; use function Functional\map; +use function Functional\unique; class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { private EntityManagerInterface $em; + /** @var array */ + private array $memoizedNewDomains = []; + /** @var array */ + private array $memoizedNewTags = []; + public function __construct(EntityManagerInterface $em) { $this->em = $em; + $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } public function resolveDomain(?string $domain): ?Domain @@ -29,7 +37,14 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** @var Domain|null $existingDomain */ $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]); - return $existingDomain ?? new Domain($domain); + + // Memoize only new domains, and let doctrine handle objects hydrated from persistence + return $existingDomain ?? $this->memoizeNewDomain($domain); + } + + private function memoizeNewDomain(string $domain): Domain + { + return $this->memoizedNewDomains[$domain] = $this->memoizedNewDomains[$domain] ?? new Domain($domain); } /** @@ -42,12 +57,26 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt return new Collections\ArrayCollection(); } + $tags = unique($tags); $repo = $this->em->getRepository(Tag::class); + return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag { - $tag = $repo->findOneBy(['name' => $tagName]) ?? new Tag($tagName); + // Memoize only new tags, and let doctrine handle objects hydrated from persistence + $tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName); $this->em->persist($tag); return $tag; })); } + + private function memoizeNewTag(string $tagName): Tag + { + return $this->memoizedNewTags[$tagName] = $this->memoizedNewTags[$tagName] ?? new Tag($tagName); + } + + public function postFlush(): void + { + $this->memoizedNewDomains = []; + $this->memoizedNewTags = []; + } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 9e094b90..4aa3579f 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -4,21 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Filter\Filter; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { private ApiKey $apiKey; - private string $dqlAlias; + private ?string $dqlAlias; public function __construct(ApiKey $apiKey, ?string $dqlAlias = null) { $this->apiKey = $apiKey; - $this->dqlAlias = $dqlAlias ?? 's'; - parent::__construct($this->dqlAlias); + $this->dqlAlias = $dqlAlias; + parent::__construct(); } protected function getSpec(): Filter diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php index 197031f3..579407cd 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php @@ -5,10 +5,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Spec; use Doctrine\ORM\QueryBuilder; -use Happyr\DoctrineSpecification\Specification\Specification; +use Happyr\DoctrineSpecification\Filter\Filter; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class BelongsToApiKeyInlined implements Specification +class BelongsToApiKeyInlined implements Filter { private ApiKey $apiKey; @@ -22,8 +22,4 @@ class BelongsToApiKeyInlined implements Specification // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later return (string) $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\''); } - - public function modify(QueryBuilder $qb, string $dqlAlias): void - { - } } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 81b4388a..7745ff27 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -4,20 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Filter\Filter; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; class BelongsToDomain extends BaseSpecification { private string $domainId; - private string $dqlAlias; + private ?string $dqlAlias; public function __construct(string $domainId, ?string $dqlAlias = null) { $this->domainId = $domainId; - $this->dqlAlias = $dqlAlias ?? 's'; - parent::__construct($this->dqlAlias); + $this->dqlAlias = $dqlAlias; + parent::__construct(); } protected function getSpec(): Filter diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php index a8ef527e..cb69a359 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Spec; use Doctrine\ORM\QueryBuilder; -use Happyr\DoctrineSpecification\Specification\Specification; +use Happyr\DoctrineSpecification\Filter\Filter; -class BelongsToDomainInlined implements Specification +class BelongsToDomainInlined implements Filter { private string $domainId; @@ -21,8 +21,4 @@ class BelongsToDomainInlined implements Specification // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later return (string) $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\''); } - - public function modify(QueryBuilder $qb, string $dqlAlias): void - { - } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index ce459714..52b98c36 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -34,7 +34,8 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), 'domain' => $shortUrl->getDomain(), - 'title' => $shortUrl->getTitle(), + 'title' => $shortUrl->title(), + 'crawlable' => $shortUrl->crawlable(), ]; } diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 44944aed..953ed9f2 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Util\DateRange; diff --git a/module/Core/src/Tag/Spec/CountTagsWithName.php b/module/Core/src/Tag/Spec/CountTagsWithName.php index a3f90a78..8dd3e44d 100644 --- a/module/Core/src/Tag/Spec/CountTagsWithName.php +++ b/module/Core/src/Tag/Spec/CountTagsWithName.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; class CountTagsWithName extends BaseSpecification diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index ae46a312..4619bd9d 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -52,7 +52,7 @@ class TagService implements TagServiceInterface { /** @var TagRepositoryInterface $repo */ $repo = $this->em->getRepository(Tag::class); - return $repo->findTagsWithInfo($apiKey !== null ? $apiKey->spec() : null); + return $repo->findTagsWithInfo($apiKey); } /** diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index b5d4fa07..c7cdaa43 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -32,6 +32,7 @@ class ShortUrlInputFilter extends InputFilter public const API_KEY = 'apiKey'; public const TAGS = 'tags'; public const TITLE = 'title'; + public const CRAWLABLE = 'crawlable'; private function __construct(array $data, bool $requireLongUrl) { @@ -105,5 +106,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($this->createTagsInput(self::TAGS, false)); $this->add($this->createInput(self::TITLE, false)); + + $this->add($this->createBooleanInput(self::CRAWLABLE, false)); } } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php new file mode 100644 index 00000000..bc9ac5de --- /dev/null +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -0,0 +1,37 @@ +dateRange = $dateRange; + $this->excludeBots = $excludeBots; + $this->spec = $spec; + } + + public function dateRange(): ?DateRange + { + return $this->dateRange; + } + + public function excludeBots(): bool + { + return $this->excludeBots; + } + + public function spec(): ?Specification + { + return $this->spec; + } +} diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php new file mode 100644 index 00000000..4f67967d --- /dev/null +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -0,0 +1,36 @@ +limit = $limit; + $this->offset = $offset; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function offset(): ?int + { + return $this->offset; + } +} diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index fb8ee3bd..b2cc9efd 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -4,27 +4,33 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; -use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Spec\InDateRange; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; class CountOfOrphanVisits extends BaseSpecification { - private ?DateRange $dateRange; + private VisitsCountFiltering $filtering; - public function __construct(?DateRange $dateRange) + public function __construct(VisitsCountFiltering $filtering) { parent::__construct(); - $this->dateRange = $dateRange; + $this->filtering = $filtering; } protected function getSpec(): Specification { - return Spec::countOf(Spec::andX( + $conditions = [ Spec::isNull('shortUrl'), - new InDateRange($this->dateRange), - )); + new InDateRange($this->filtering->dateRange()), + ]; + + if ($this->filtering->excludeBots()) { + $conditions[] = Spec::eq('potentialBot', false); + } + + return Spec::countOf(Spec::andX(...$conditions)); } } diff --git a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php index 6a125ee9..ea4a4800 100644 --- a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php +++ b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; diff --git a/module/Core/src/Visit/VisitLocator.php b/module/Core/src/Visit/VisitLocator.php index 46a30559..d7f0e426 100644 --- a/module/Core/src/Visit/VisitLocator.php +++ b/module/Core/src/Visit/VisitLocator.php @@ -63,8 +63,7 @@ class VisitLocator implements VisitLocatorInterface $location = Location::emptyInstance(); } - $location = new VisitLocation($location); - $this->locateVisit($visit, $location, $helper); + $this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper); // Flush and clear after X iterations if ($count % $persistBlock === 0) { diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 61d879fd..dfa00a4c 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -22,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\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface @@ -38,7 +39,10 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** @var VisitRepository $visitsRepo */ $visitsRepo = $this->em->getRepository(Visit::class); - return new VisitsStats($visitsRepo->countVisits($apiKey), $visitsRepo->countOrphanVisits()); + return new VisitsStats( + $visitsRepo->countVisits($apiKey), + $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + ); } /** @@ -54,7 +58,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain(), $spec)) { + if (! $repo->shortCodeIsInUse($identifier, $spec)) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 306da7a9..f77cd624 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -10,18 +10,18 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Options\TrackingOptions; class VisitsTracker implements VisitsTrackerInterface { private ORM\EntityManagerInterface $em; private EventDispatcherInterface $eventDispatcher; - private UrlShortenerOptions $options; + private TrackingOptions $options; public function __construct( ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher, - UrlShortenerOptions $options + TrackingOptions $options ) { $this->em = $em; $this->eventDispatcher = $eventDispatcher; @@ -31,43 +31,56 @@ class VisitsTracker implements VisitsTrackerInterface public function track(ShortUrl $shortUrl, Visitor $visitor): void { $this->trackVisit( - Visit::forValidShortUrl($shortUrl, $visitor, $this->options->anonymizeRemoteAddr()), + fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr()), $visitor, ); } public function trackInvalidShortUrlVisit(Visitor $visitor): void { - if (! $this->options->trackOrphanVisits()) { - return; - } - - $this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->options->anonymizeRemoteAddr()), $visitor); + $this->trackOrphanVisit( + fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr()), + $visitor, + ); } public function trackBaseUrlVisit(Visitor $visitor): void { - if (! $this->options->trackOrphanVisits()) { - return; - } - - $this->trackVisit(Visit::forBasePath($visitor, $this->options->anonymizeRemoteAddr()), $visitor); + $this->trackOrphanVisit( + fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr()), + $visitor, + ); } public function trackRegularNotFoundVisit(Visitor $visitor): void + { + $this->trackOrphanVisit( + fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr()), + $visitor, + ); + } + + private function trackOrphanVisit(callable $createVisit, Visitor $visitor): void { if (! $this->options->trackOrphanVisits()) { return; } - $this->trackVisit(Visit::forRegularNotFound($visitor, $this->options->anonymizeRemoteAddr()), $visitor); + $this->trackVisit($createVisit, $visitor); } - private function trackVisit(Visit $visit, Visitor $visitor): void + private function trackVisit(callable $createVisit, Visitor $visitor): void { - $this->em->persist($visit); - $this->em->flush(); + if ($this->options->disableTracking()) { + return; + } - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); + $this->em->transactional(function () use ($visit, $visitor): void { + $this->em->persist($visit); + $this->em->flush(); + + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + }); } } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 49265eb0..aaa63d9f 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -53,9 +54,9 @@ class DomainRepositoryTest extends DatabaseTestCase /** @test */ public function findDomainsReturnsJustThoseMatchingProvidedApiKey(): void { - $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorApiKey); - $authorAndDomainApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $authorAndDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorAndDomainApiKey); $fooDomain = new Domain('foo.com'); @@ -74,10 +75,10 @@ class DomainRepositoryTest extends DatabaseTestCase $authorAndDomainApiKey->registerRole(RoleDefinition::forDomain($fooDomain)); - $fooDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($fooDomain)); + $fooDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($fooDomain))); $this->getEntityManager()->persist($fooDomainApiKey); - $barDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($barDomain)); + $barDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($barDomain))); $this->getEntityManager()->persist($fooDomainApiKey); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 48381857..867ff3f2 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -11,12 +11,14 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -179,12 +181,18 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertTrue($this->repo->shortCodeIsInUse('my-cool-slug')); - self::assertFalse($this->repo->shortCodeIsInUse('my-cool-slug', 'doma.in')); - self::assertFalse($this->repo->shortCodeIsInUse('slug-not-in-use')); - self::assertFalse($this->repo->shortCodeIsInUse('another-slug')); - self::assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com')); - self::assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in')); + self::assertTrue($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); + self::assertFalse($this->repo->shortCodeIsInUse( + ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'), + )); + self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); + self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); + self::assertFalse($this->repo->shortCodeIsInUse( + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), + )); + self::assertTrue($this->repo->shortCodeIsInUse( + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + )); } /** @test */ @@ -202,12 +210,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertNotNull($this->repo->findOne('my-cool-slug')); - self::assertNull($this->repo->findOne('my-cool-slug', 'doma.in')); - self::assertNull($this->repo->findOne('slug-not-in-use')); - self::assertNull($this->repo->findOne('another-slug')); - self::assertNull($this->repo->findOne('another-slug', 'example.com')); - self::assertNotNull($this->repo->findOne('another-slug', 'doma.in')); + self::assertNotNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); + self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); + self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); + self::assertNull($this->repo->findOne( + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), + )); + self::assertNotNull($this->repo->findOne( + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + )); } /** @test */ @@ -335,13 +347,13 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $apiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey); - $otherApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $otherApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($otherApiKey); - $wrongDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($wrongDomain)); + $wrongDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($wrongDomain))); $this->getEntityManager()->persist($wrongDomainApiKey); - $rightDomainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($rightDomain)); + $rightDomainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($rightDomain))); $this->getEntityManager()->persist($rightDomainApiKey); $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ @@ -415,7 +427,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase } /** @test */ - public function importedShortUrlsAreSearchedAsExpected(): void + public function importedShortUrlsAreFoundWhenExpected(): void { $buildImported = static fn (string $shortCode, ?String $domain = null) => new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null); @@ -428,11 +440,44 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertTrue($this->repo->importedUrlExists($buildImported('my-cool-slug'))); - self::assertTrue($this->repo->importedUrlExists($buildImported('another-slug', 'doma.in'))); - self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug'))); - self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug', 'doma.in'))); - self::assertFalse($this->repo->importedUrlExists($buildImported('my-cool-slug', 'doma.in'))); - self::assertFalse($this->repo->importedUrlExists($buildImported('another-slug'))); + self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug'))); + self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 'doma.in'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 'doma.in'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('another-slug'))); + } + + /** @test */ + public function findCrawlableShortCodesReturnsExpectedResult(): void + { + $createShortUrl = fn (bool $crawlable) => ShortUrl::fromMeta( + ShortUrlMeta::fromRawData(['crawlable' => $crawlable, 'longUrl' => 'foo.com']), + ); + + $shortUrl1 = $createShortUrl(true); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = $createShortUrl(false); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = $createShortUrl(true); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = $createShortUrl(true); + $this->getEntityManager()->persist($shortUrl4); + $shortUrl5 = $createShortUrl(false); + $this->getEntityManager()->persist($shortUrl5); + $this->getEntityManager()->flush(); + + $iterable = $this->repo->findCrawlableShortCodes(); + $results = []; + foreach ($iterable as $shortCode) { + $results[] = $shortCode; + } + + self::assertCount(3, $results); + self::assertContains($shortUrl1->getShortCode(), $results); + self::assertContains($shortUrl3->getShortCode(), $results); + self::assertContains($shortUrl4->getShortCode(), $results); + self::assertNotContains($shortUrl2->getShortCode(), $results); + self::assertNotContains($shortUrl5->getShortCode(), $results); } } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 34a06a40..eea2ed8c 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -100,9 +101,9 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($domain); $this->getEntityManager()->flush(); - $authorApiKey = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($authorApiKey); - $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain)); + $domainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); $this->getEntityManager()->persist($domainApiKey); $names = ['foo', 'bar', 'baz', 'another']; diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index b6c23699..15fe34f4 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -11,11 +11,15 @@ use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\IpGeolocation\Model\Location; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -56,7 +60,7 @@ class VisitRepositoryTest extends DatabaseTestCase $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); if ($i >= 2) { - $location = new VisitLocation(Location::emptyInstance()); + $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->getEntityManager()->persist($location); $visit->locate($location); } @@ -86,22 +90,48 @@ class VisitRepositoryTest extends DatabaseTestCase { [$shortCode, $domain] = $this->createShortUrlsAndVisits(); - self::assertCount(0, $this->repo->findVisitsByShortCode('invalid')); - self::assertCount(6, $this->repo->findVisitsByShortCode($shortCode)); - self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain)); - self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( - Chronos::parse('2016-01-02'), - Chronos::parse('2016-01-03'), - ))); - self::assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( - Chronos::parse('2016-01-03'), - ))); - self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange( - Chronos::parse('2016-01-03'), - ))); - self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2)); - self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4)); - self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2)); + self::assertCount(0, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain('invalid'), + new VisitsListFiltering(), + )); + self::assertCount(6, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering(), + )); + self::assertCount(4, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering(null, true), + )); + self::assertCount(3, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), + new VisitsListFiltering(), + )); + self::assertCount(2, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ), + )); + self::assertCount(4, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + )); + self::assertCount(1, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), + new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + )); + self::assertCount(3, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering(null, false, null, 3, 2), + )); + self::assertCount(2, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsListFiltering(null, false, null, 5, 4), + )); + self::assertCount(1, $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), + new VisitsListFiltering(null, false, null, 3, 2), + )); } /** @test */ @@ -109,19 +139,36 @@ class VisitRepositoryTest extends DatabaseTestCase { [$shortCode, $domain] = $this->createShortUrlsAndVisits(); - self::assertEquals(0, $this->repo->countVisitsByShortCode('invalid')); - self::assertEquals(6, $this->repo->countVisitsByShortCode($shortCode)); - self::assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain)); - self::assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( - Chronos::parse('2016-01-02'), - Chronos::parse('2016-01-03'), - ))); - self::assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( - Chronos::parse('2016-01-03'), - ))); - self::assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange( - Chronos::parse('2016-01-03'), - ))); + self::assertEquals(0, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain('invalid'), + new VisitsCountFiltering(), + )); + self::assertEquals(6, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsCountFiltering(), + )); + self::assertEquals(4, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsCountFiltering(null, true), + )); + self::assertEquals(3, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), + new VisitsCountFiltering(), + )); + self::assertEquals(2, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsCountFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ), + )); + self::assertEquals(4, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + )); + self::assertEquals(1, $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), + new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + )); } /** @test */ @@ -139,13 +186,15 @@ class VisitRepositoryTest extends DatabaseTestCase $this->createShortUrlsAndVisits(false, [$foo]); $this->getEntityManager()->flush(); - self::assertCount(0, $this->repo->findVisitsByTag('invalid')); - self::assertCount(18, $this->repo->findVisitsByTag($foo)); - self::assertCount(6, $this->repo->findVisitsByTag($foo, new DateRange( - Chronos::parse('2016-01-02'), - Chronos::parse('2016-01-03'), + self::assertCount(0, $this->repo->findVisitsByTag('invalid', new VisitsListFiltering())); + self::assertCount(18, $this->repo->findVisitsByTag($foo, new VisitsListFiltering())); + self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(null, true))); + self::assertCount(6, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), ))); - self::assertCount(12, $this->repo->findVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03')))); } /** @test */ @@ -159,13 +208,15 @@ class VisitRepositoryTest extends DatabaseTestCase $this->createShortUrlsAndVisits(false, [$foo]); $this->getEntityManager()->flush(); - self::assertEquals(0, $this->repo->countVisitsByTag('invalid')); - self::assertEquals(12, $this->repo->countVisitsByTag($foo)); - self::assertEquals(4, $this->repo->countVisitsByTag($foo, new DateRange( - Chronos::parse('2016-01-02'), - Chronos::parse('2016-01-03'), + self::assertEquals(0, $this->repo->countVisitsByTag('invalid', new VisitsCountFiltering())); + self::assertEquals(12, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering())); + self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(null, true))); + self::assertEquals(4, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( + DateRange::withStartDate(Chronos::parse('2016-01-03')), ))); - self::assertEquals(8, $this->repo->countVisitsByTag($foo, new DateRange(Chronos::parse('2016-01-03')))); } /** @test */ @@ -176,7 +227,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $apiKey1 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::fromMeta( ShortUrlMeta::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), @@ -185,7 +236,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 4); - $apiKey2 = ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()); + $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey2); $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); $this->getEntityManager()->persist($shortUrl2); @@ -198,13 +249,14 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl3); $this->createVisitsForShortUrl($shortUrl3, 7); - $domainApiKey = ApiKey::withRoles(RoleDefinition::forDomain($domain)); + $domainApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); $this->getEntityManager()->persist($domainApiKey); // Visits not linked to any short URL $this->getEntityManager()->persist(Visit::forBasePath(Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::botInstance())); $this->getEntityManager()->flush(); @@ -212,7 +264,8 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(4, $this->repo->countVisits($apiKey1)); self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2)); self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey)); - self::assertEquals(3, $this->repo->countOrphanVisits()); + self::assertEquals(4, $this->repo->countOrphanVisits(new VisitsCountFiltering())); + self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true))); } /** @test */ @@ -222,9 +275,10 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); + $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( @@ -235,20 +289,32 @@ class VisitRepositoryTest extends DatabaseTestCase Visit::forRegularNotFound(Visitor::emptyInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); + + $botsCount--; } $this->getEntityManager()->flush(); - self::assertCount(18, $this->repo->findOrphanVisits()); - self::assertCount(5, $this->repo->findOrphanVisits(null, 5)); - self::assertCount(10, $this->repo->findOrphanVisits(null, 15, 8)); - self::assertCount(9, $this->repo->findOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')), 15)); - self::assertCount(2, $this->repo->findOrphanVisits( + self::assertCount(18, $this->repo->findOrphanVisits(new VisitsListFiltering())); + self::assertCount(15, $this->repo->findOrphanVisits(new VisitsListFiltering(null, true))); + self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); + self::assertCount(10, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 15, 8))); + self::assertCount(9, $this->repo->findOrphanVisits(new VisitsListFiltering( + DateRange::withStartDate(Chronos::parse('2020-01-04')), + false, + null, + 15, + ))); + self::assertCount(2, $this->repo->findOrphanVisits(new VisitsListFiltering( DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + false, + null, 6, 4, - )); - self::assertCount(3, $this->repo->findOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01')))); + ))); + self::assertCount(3, $this->repo->findOrphanVisits(new VisitsListFiltering( + DateRange::withEndDate(Chronos::parse('2020-01-01')), + ))); } /** @test */ @@ -275,13 +341,17 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals(18, $this->repo->countOrphanVisits()); - self::assertEquals(18, $this->repo->countOrphanVisits(DateRange::emptyInstance())); - self::assertEquals(9, $this->repo->countOrphanVisits(DateRange::withStartDate(Chronos::parse('2020-01-04')))); - self::assertEquals(6, $this->repo->countOrphanVisits( - DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering())); + self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::emptyInstance()))); + self::assertEquals(9, $this->repo->countOrphanVisits( + new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2020-01-04'))), + )); + self::assertEquals(6, $this->repo->countOrphanVisits(new VisitsCountFiltering( + DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + ))); + self::assertEquals(3, $this->repo->countOrphanVisits( + new VisitsCountFiltering(DateRange::withEndDate(Chronos::parse('2020-01-01'))), )); - self::assertEquals(3, $this->repo->countOrphanVisits(DateRange::withEndDate(Chronos::parse('2020-01-01')))); } private function createShortUrlsAndVisits(bool $withDomain = true, array $tags = []): array @@ -310,13 +380,17 @@ class VisitRepositoryTest extends DatabaseTestCase return [$shortCode, $domain, $shortUrl]; } - private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6): void + private function createVisitsForShortUrl(ShortUrl $shortUrl, int $amount = 6, int $botsAmount = 2): void { for ($i = 0; $i < $amount; $i++) { $visit = $this->setDateOnVisit( - Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + Visit::forValidShortUrl( + $shortUrl, + $botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(), + ), Chronos::parse(sprintf('2016-01-0%s', $i + 1)), ); + $botsAmount--; $this->getEntityManager()->persist($visit); } diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 065cc2c4..6df2498a 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Common\Response\PixelResponse; use Shlinkio\Shlink\Core\Action\PixelAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\Options\AppOptions; +use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\VisitsTracker; @@ -34,7 +34,7 @@ class PixelActionTest extends TestCase $this->action = new PixelAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), - new AppOptions(), + new TrackingOptions(), ); } diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index f869e2c4..dde9144c 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -42,7 +42,7 @@ class RedirectActionTest extends TestCase $this->action = new RedirectAction( $this->urlResolver->reveal(), $this->visitTracker->reveal(), - new Options\AppOptions(['disableTrackParam' => 'foobar']), + new Options\TrackingOptions(['disableTrackParam' => 'foobar']), $this->redirectRespHelper->reveal(), ); } diff --git a/module/Core/test/Action/RobotsActionTest.php b/module/Core/test/Action/RobotsActionTest.php new file mode 100644 index 00000000..ad8a02d1 --- /dev/null +++ b/module/Core/test/Action/RobotsActionTest.php @@ -0,0 +1,75 @@ +helper = $this->prophesize(CrawlingHelperInterface::class); + $this->action = new RobotsAction($this->helper->reveal()); + } + + /** + * @test + * @dataProvider provideShortCodes + */ + public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void + { + $getShortCodes = $this->helper->listCrawlableShortCodes()->willReturn($shortCodes); + + $response = $this->action->handle(ServerRequestFactory::fromGlobals()); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expected, $response->getBody()->__toString()); + self::assertEquals('text/plain', $response->getHeaderLine('Content-Type')); + $getShortCodes->shouldHaveBeenCalledOnce(); + } + + public function provideShortCodes(): iterable + { + yield 'three short codes' => [['foo', 'bar', 'baz'], << [['foo', 'bar', 'some', 'thing', 'baz'], << [[], << [ + 'tracking' => [ 'disable_track_param' => 'foo', ], @@ -70,8 +70,9 @@ class SimplifiedConfigParserTest extends TestCase 'port' => 8888, ]; $expected = [ - 'app_options' => [ + 'tracking' => [ 'disable_track_param' => 'bar', + 'anonymize_remote_addr' => false, ], 'entity_manager' => [ @@ -96,7 +97,6 @@ class SimplifiedConfigParserTest extends TestCase 'https://third-party.io/foo', ], 'default_short_codes_length' => 8, - 'anonymize_remote_addr' => false, 'redirect_status_code' => 301, 'redirect_cache_lifetime' => 90, ], diff --git a/module/Core/test/Crawling/CrawlingHelperTest.php b/module/Core/test/Crawling/CrawlingHelperTest.php new file mode 100644 index 00000000..2c65ebac --- /dev/null +++ b/module/Core/test/Crawling/CrawlingHelperTest.php @@ -0,0 +1,43 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->helper = new CrawlingHelper($this->em->reveal()); + } + + /** @test */ + public function listCrawlableShortCodesDelegatesIntoRepository(): void + { + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $findCrawlableShortCodes = $repo->findCrawlableShortCodes()->willReturn([]); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $result = $this->helper->listCrawlableShortCodes(); + foreach ($result as $shortCode) { + // Result is a generator and therefore, it needs to be iterated + } + + $findCrawlableShortCodes->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 46e39c5a..0306f387 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -50,8 +51,10 @@ class DomainServiceTest extends TestCase public function provideExcludedDomains(): iterable { $default = new DomainItem('default.com', true); - $adminApiKey = new ApiKey(); - $domainSpecificApiKey = ApiKey::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))); + $adminApiKey = ApiKey::create(); + $domainSpecificApiKey = ApiKey::fromMeta( + ApiKeyMeta::withRoles(RoleDefinition::forDomain((new Domain(''))->setId('123'))), + ); yield 'empty list without API key' => [[], [$default], null]; yield 'one item without API key' => [ diff --git a/module/Core/test/Entity/VisitLocationTest.php b/module/Core/test/Entity/VisitLocationTest.php index 057a1920..6021b124 100644 --- a/module/Core/test/Entity/VisitLocationTest.php +++ b/module/Core/test/Entity/VisitLocationTest.php @@ -17,7 +17,7 @@ class VisitLocationTest extends TestCase public function isEmptyReturnsTrueWhenAllValuesAreEmpty(array $args, bool $isEmpty): void { $payload = new Location(...$args); - $location = new VisitLocation($payload); + $location = VisitLocation::fromGeolocation($payload); self::assertEquals($isEmpty, $location->isEmpty()); } diff --git a/module/Core/test/Entity/VisitTest.php b/module/Core/test/Entity/VisitTest.php index 7be3c3fc..2d2cb4f8 100644 --- a/module/Core/test/Entity/VisitTest.php +++ b/module/Core/test/Entity/VisitTest.php @@ -12,19 +12,35 @@ use Shlinkio\Shlink\Core\Model\Visitor; class VisitTest extends TestCase { - /** @test */ - public function isProperlyJsonSerialized(): void + /** + * @test + * @dataProvider provideUserAgents + */ + public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('Chrome', 'some site', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); self::assertEquals([ 'referer' => 'some site', 'date' => $visit->getDate()->toAtomString(), - 'userAgent' => 'Chrome', + 'userAgent' => $userAgent, 'visitLocation' => null, + 'potentialBot' => $expectedToBePotentialBot, ], $visit->jsonSerialize()); } + public function provideUserAgents(): iterable + { + yield 'Chrome' => [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36', + false, + ]; + yield 'Firefox' => ['Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', false]; + yield 'Facebook' => ['cf-facebook', true]; + yield 'Twitter' => ['IDG Twitter Links Resolver', true]; + yield 'Guzzle' => ['guzzlehttp', true]; + } + /** * @test * @dataProvider provideAddresses diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 081f0f86..406e8146 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; +use OutOfRangeException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; -use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; @@ -22,6 +21,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\LocateVisit; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; @@ -41,9 +41,11 @@ class LocateVisitTest extends TestCase $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); + $this->dbUpdater->databaseFileExists()->willReturn(true); + $this->locateVisit = new LocateVisit( $this->ipLocationResolver->reveal(), $this->em->reveal(), @@ -73,6 +75,31 @@ class LocateVisitTest extends TestCase $dispatch->shouldNotHaveBeenCalled(); } + /** @test */ + public function nonExistingGeoLiteDbLogsWarning(): void + { + $event = new UrlVisited('123'); + $findVisit = $this->em->find(Visit::class, '123')->willReturn( + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + ); + $dbExists = $this->dbUpdater->databaseFileExists()->willReturn(false); + $logWarning = $this->logger->warning( + 'Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', + ['visitId' => 123], + ); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { + }); + + ($this->locateVisit)($event); + + $findVisit->shouldHaveBeenCalledOnce(); + $dbExists->shouldHaveBeenCalledOnce(); + $this->em->flush()->shouldNotHaveBeenCalled(); + $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled(); + $logWarning->shouldHaveBeenCalled(); + $dispatch->shouldHaveBeenCalledOnce(); + } + /** @test */ public function invalidAddressLogsWarning(): void { @@ -84,7 +111,7 @@ class LocateVisitTest extends TestCase WrongIpException::class, ); $logWarning = $this->logger->warning( - Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'), + 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', Argument::type('array'), ); $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { @@ -99,6 +126,32 @@ class LocateVisitTest extends TestCase $dispatch->shouldHaveBeenCalledOnce(); } + /** @test */ + public function unhandledExceptionLogsError(): void + { + $event = new UrlVisited('123'); + $findVisit = $this->em->find(Visit::class, '123')->willReturn( + Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + ); + $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow( + OutOfRangeException::class, + ); + $logError = $this->logger->error( + 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', + Argument::type('array'), + ); + $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { + }); + + ($this->locateVisit)($event); + + $findVisit->shouldHaveBeenCalledOnce(); + $resolveLocation->shouldHaveBeenCalledOnce(); + $logError->shouldHaveBeenCalled(); + $this->em->flush()->shouldNotHaveBeenCalled(); + $dispatch->shouldHaveBeenCalledOnce(); + } + /** * @test * @dataProvider provideNonLocatableVisits @@ -115,7 +168,7 @@ class LocateVisitTest extends TestCase ($this->locateVisit)($event); - self::assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance())); + self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance())); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldNotHaveBeenCalled(); @@ -151,7 +204,7 @@ class LocateVisitTest extends TestCase ($this->locateVisit)($event); - self::assertEquals($visit->getVisitLocation(), new VisitLocation($location)); + self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location)); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); @@ -173,67 +226,4 @@ class LocateVisitTest extends TestCase yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; } - - /** @test */ - public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void - { - $e = GeolocationDbUpdateFailedException::withOlderDb(); - $ipAddr = '1.2.3.0'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); - $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new UrlVisited('123'); - - $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush()->will(function (): void { - }); - $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); - $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); - - ($this->locateVisit)($event); - - self::assertEquals($visit->getVisitLocation(), new VisitLocation($location)); - $findVisit->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); - $resolveIp->shouldHaveBeenCalledOnce(); - $checkUpdateDb->shouldHaveBeenCalledOnce(); - $this->logger->warning( - 'GeoLite2 database update failed. Proceeding with old version. {e}', - ['e' => $e], - )->shouldHaveBeenCalledOnce(); - $dispatch->shouldHaveBeenCalledOnce(); - } - - /** @test */ - public function errorWhenDownloadingGeoLiteCancelsLocation(): void - { - $e = GeolocationDbUpdateFailedException::withoutOlderDb(); - $ipAddr = '1.2.3.0'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $ipAddr, '')); - $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new UrlVisited('123'); - - $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush()->will(function (): void { - }); - $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); - $checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); - $logError = $this->logger->error( - 'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}', - ['e' => $e, 'visitId' => 123], - ); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); - - ($this->locateVisit)($event); - - self::assertNull($visit->getVisitLocation()); - $findVisit->shouldHaveBeenCalledOnce(); - $flush->shouldNotHaveBeenCalled(); - $resolveIp->shouldNotHaveBeenCalled(); - $checkUpdateDb->shouldHaveBeenCalledOnce(); - $logError->shouldHaveBeenCalledOnce(); - $dispatch->shouldHaveBeenCalledOnce(); - } } diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index f323a155..0b863b69 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Model\Visitor; -use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; class NotifyVisitToMercureTest extends TestCase @@ -25,20 +25,20 @@ class NotifyVisitToMercureTest extends TestCase use ProphecyTrait; private NotifyVisitToMercure $listener; - private ObjectProphecy $publisher; + private ObjectProphecy $hub; private ObjectProphecy $updatesGenerator; private ObjectProphecy $em; private ObjectProphecy $logger; public function setUp(): void { - $this->publisher = $this->prophesize(PublisherInterface::class); + $this->hub = $this->prophesize(HubInterface::class); $this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->listener = new NotifyVisitToMercure( - $this->publisher->reveal(), + $this->hub->reveal(), $this->updatesGenerator->reveal(), $this->em->reveal(), $this->logger->reveal(), @@ -60,7 +60,7 @@ class NotifyVisitToMercureTest extends TestCase ); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class)); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class)); - $publish = $this->publisher->__invoke(Argument::type(Update::class)); + $publish = $this->hub->publish(Argument::type(Update::class)); ($this->listener)(new VisitLocated($visitId)); @@ -86,7 +86,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->publisher->__invoke($update); + $publish = $this->hub->publish($update); ($this->listener)(new VisitLocated($visitId)); @@ -115,7 +115,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->publisher->__invoke($update)->willThrow($e); + $publish = $this->hub->publish($update)->willThrow($e); ($this->listener)(new VisitLocated($visitId)); @@ -143,7 +143,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->publisher->__invoke($update); + $publish = $this->hub->publish($update); ($this->listener)(new VisitLocated($visitId)); diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php new file mode 100644 index 00000000..a492f9dd --- /dev/null +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -0,0 +1,118 @@ +dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new UpdateGeoLiteDb($this->dbUpdater->reveal(), $this->logger->reveal()); + } + + /** @test */ + public function exceptionWhileUpdatingDbLogsError(): void + { + $e = new RuntimeException(); + + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); + $logError = $this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]); + + ($this->listener)(); + + $checkDbUpdate->shouldHaveBeenCalledOnce(); + $logError->shouldHaveBeenCalledOnce(); + $this->logger->notice(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideFlags + */ + public function noticeMessageIsPrintedWhenFirstCallbackIsInvoked(bool $oldDbExists, string $expectedMessage): void + { + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( + function (array $args) use ($oldDbExists): void { + [$firstCallback] = $args; + $firstCallback($oldDbExists); + }, + ); + $logNotice = $this->logger->notice($expectedMessage); + + ($this->listener)(); + + $checkDbUpdate->shouldHaveBeenCalledOnce(); + $logNotice->shouldHaveBeenCalledOnce(); + $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideFlags(): iterable + { + yield 'existing old db' => [true, 'Updating GeoLite2 db file...']; + yield 'not existing old db' => [false, 'Downloading GeoLite2 db file...']; + } + + /** + * @test + * @dataProvider provideDownloaded + */ + public function noticeMessageIsPrintedWhenSecondCallbackIsInvoked( + int $total, + int $downloaded, + bool $oldDbExists, + ?string $expectedMessage + ): void { + $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( + function (array $args) use ($total, $downloaded, $oldDbExists): void { + [, $secondCallback] = $args; + + // Invoke several times to ensure the log is printed only once + $secondCallback($total, $downloaded, $oldDbExists); + $secondCallback($total, $downloaded, $oldDbExists); + $secondCallback($total, $downloaded, $oldDbExists); + }, + ); + $logNotice = $this->logger->notice($expectedMessage ?? Argument::cetera()); + + ($this->listener)(); + + if ($expectedMessage !== null) { + $logNotice->shouldHaveBeenCalledOnce(); + } else { + $logNotice->shouldNotHaveBeenCalled(); + } + $checkDbUpdate->shouldHaveBeenCalledOnce(); + $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideDownloaded(): iterable + { + yield [100, 0, true, null]; + yield [100, 0, false, null]; + yield [100, 99, true, null]; + yield [100, 99, false, null]; + yield [100, 100, true, 'Finished updating GeoLite2 db file']; + yield [100, 100, false, 'Finished downloading GeoLite2 db file']; + yield [100, 101, true, 'Finished updating GeoLite2 db file']; + yield [100, 101, false, 'Finished downloading GeoLite2 db file']; + } +} diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index c294ffe5..d17c5720 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -5,18 +5,22 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Importer; use Cake\Chronos\Chronos; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; 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\Sources\ImportSources; use Symfony\Component\Console\Style\StyleInterface; use function count; @@ -28,6 +32,8 @@ 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; @@ -64,11 +70,11 @@ class ImportedLinksProcessorTest extends TestCase ]; $expectedCalls = count($urls); - $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false); + $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + $this->processor->process($this->io->reveal(), $urls, self::PARAMS); $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls); $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); @@ -86,24 +92,25 @@ class ImportedLinksProcessorTest extends TestCase new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null), ]; - $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); - $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->will(function (array $args): bool { - /** @var ImportedShlinkUrl $url */ - [$url] = $args; + $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->will( + function (array $args): ?ShortUrl { + /** @var ImportedShlinkUrl $url */ + [$url] = $args; - return contains(['foo', 'baz2', 'baz3'], $url->longUrl()); - }); + return contains(['foo', 'baz2', 'baz3'], $url->longUrl()) ? ShortUrl::fromImport($url, true) : null; + }, + ); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + $this->processor->process($this->io->reveal(), $urls, self::PARAMS); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $ensureUniqueness->shouldHaveBeenCalledTimes(2); $persist->shouldHaveBeenCalledTimes(2); - $this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3); - $this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2); + $this->io->text(Argument::containingString('Skipped'))->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); } /** @test */ @@ -116,9 +123,8 @@ class ImportedLinksProcessorTest extends TestCase new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'), ]; - $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); - $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false); + $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); $failingEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness( Argument::any(), true, @@ -135,14 +141,77 @@ class ImportedLinksProcessorTest extends TestCase }); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + $this->processor->process($this->io->reveal(), $urls, self::PARAMS); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); $successEnsureUniqueness->shouldHaveBeenCalledTimes(2); $choice->shouldHaveBeenCalledTimes(5); $persist->shouldHaveBeenCalledTimes(2); - $this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3); - $this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2); + $this->io->text(Argument::containingString('Error'))->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); + } + + /** + * @test + * @dataProvider provideUrlsWithVisits + */ + public function properAmountOfVisitsIsImported( + ImportedShlinkUrl $importedUrl, + string $expectedOutput, + int $amountOfPersistedVisits, + ?ShortUrl $foundShortUrl + ): void { + $findExisting = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn($foundShortUrl); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $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); + + $findExisting->shouldHaveBeenCalledOnce(); + $ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); + $persistUrl->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); + $persistVisits->shouldHaveBeenCalledTimes($amountOfPersistedVisits); + $this->io->text(Argument::containingString($expectedOutput))->shouldHaveBeenCalledOnce(); + } + + public function provideUrlsWithVisits(): iterable + { + $now = Chronos::now(); + $createImportedUrl = fn (array $visits) => new ImportedShlinkUrl('', 's', [], $now, null, 's', null, $visits); + + yield 'new short URL' => [$createImportedUrl([ + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + ]), 'Imported with 5 visits', 5, null]; + yield 'existing short URL without previous imported visits' => [ + $createImportedUrl([ + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now->addDays(3), null), + new ImportedShlinkVisit('', '', $now->addDays(3), null), + ]), + 'Skipped. Imported 4 visits', + 4, + ShortUrl::createEmpty(), + ]; + yield 'existing short URL with previous imported visits' => [ + $createImportedUrl([ + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now, null), + new ImportedShlinkVisit('', '', $now->addDays(3), null), + new ImportedShlinkVisit('', '', $now->addDays(3), null), + ]), + 'Skipped. Imported 2 visits', + 2, + ShortUrl::createEmpty()->setVisits(new ArrayCollection([ + Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)), + ])), + ]; } } diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index b4361ca5..86d1b3d5 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -59,12 +59,14 @@ class MercureUpdatesGeneratorTest extends TestCase ], 'domain' => null, 'title' => $title, + 'crawlable' => false, ], 'visit' => [ 'referer' => '', 'userAgent' => '', 'visitLocation' => null, 'date' => $visit->getDate()->toAtomString(), + 'potentialBot' => false, ], ], json_decode($update->getData())); } @@ -90,6 +92,7 @@ class MercureUpdatesGeneratorTest extends TestCase 'userAgent' => '', 'visitLocation' => null, 'date' => $orphanVisit->getDate()->toAtomString(), + 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl(), 'type' => $orphanVisit->type(), ], diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index e1003056..50c277c4 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Model; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Options\TrackingOptions; use function random_int; use function str_repeat; @@ -71,4 +72,28 @@ class VisitorTest extends TestCase } return $randomString; } + + /** @test */ + public function newNormalizedInstanceIsCreatedFromTrackingOptions(): void + { + $visitor = new Visitor( + $this->generateRandomString(2000), + $this->generateRandomString(2000), + $this->generateRandomString(2000), + $this->generateRandomString(2000), + ); + $normalizedVisitor = $visitor->normalizeForTrackingOptions(new TrackingOptions([ + 'disableIpTracking' => true, + 'disableReferrerTracking' => true, + 'disableUaTracking' => true, + ])); + + self::assertNotSame($visitor, $normalizedVisitor); + self::assertEmpty($normalizedVisitor->getUserAgent()); + self::assertNotEmpty($visitor->getUserAgent()); + self::assertEmpty($normalizedVisitor->getReferer()); + self::assertNotEmpty($visitor->getReferer()); + self::assertNull($normalizedVisitor->getRemoteAddress()); + self::assertNotNull($visitor->getRemoteAddress()); + } } diff --git a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 6b28aa68..1cc21eef 100644 --- a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; class OrphanVisitsPaginatorAdapterTest extends TestCase { @@ -32,7 +34,9 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase public function countDelegatesToRepository(): void { $expectedCount = 5; - $repoCount = $this->repo->countOrphanVisits($this->params->getDateRange())->willReturn($expectedCount); + $repoCount = $this->repo->countOrphanVisits( + new VisitsCountFiltering($this->params->getDateRange()), + )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -48,7 +52,9 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $repoFind = $this->repo->findOrphanVisits($this->params->getDateRange(), $limit, $offset)->willReturn($list); + $repoFind = $this->repo->findOrphanVisits( + new VisitsListFiltering($this->params->getDateRange(), $this->params->excludeBots(), null, $limit, $offset), + )->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 93aba122..5420e4b6 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -66,7 +66,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'startDate' => $startDate, 'endDate' => $endDate, ]); - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey); $dateRange = $params->dateRange(); diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 8dc88495..aa684b70 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapterTest extends TestCase @@ -31,7 +33,10 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $limit = 1; $offset = 5; $adapter = $this->createAdapter(null); - $findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset, null)->willReturn([]); + $findVisits = $this->repo->findVisitsByTag( + 'foo', + new VisitsListFiltering(new DateRange(), false, null, $limit, $offset), + )->willReturn([]); for ($i = 0; $i < $count; $i++) { $adapter->getSlice($offset, $limit); @@ -44,9 +49,12 @@ class VisitsForTagPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $adapter = $this->createAdapter($apiKey); - $countVisits = $this->repo->countVisitsByTag('foo', new DateRange(), $apiKey->spec())->willReturn(3); + $countVisits = $this->repo->countVisitsByTag( + 'foo', + new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()), + )->willReturn(3); for ($i = 0; $i < $count; $i++) { $adapter->getNbResults(); diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 436b4b7d..2a9e5fc4 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -12,6 +12,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsPaginatorAdapterTest extends TestCase @@ -32,9 +34,10 @@ class VisitsPaginatorAdapterTest extends TestCase $limit = 1; $offset = 5; $adapter = $this->createAdapter(null); - $findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset, null)->willReturn( - [], - ); + $findVisits = $this->repo->findVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain(''), + new VisitsListFiltering(new DateRange(), false, null, $limit, $offset), + )->willReturn([]); for ($i = 0; $i < $count; $i++) { $adapter->getSlice($offset, $limit); @@ -47,9 +50,12 @@ class VisitsPaginatorAdapterTest extends TestCase public function repoIsCalledOnlyOnceForCount(): void { $count = 3; - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $adapter = $this->createAdapter($apiKey); - $countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange(), $apiKey->spec())->willReturn(3); + $countVisits = $this->repo->countVisitsByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain(''), + new VisitsCountFiltering(new DateRange(), false, $apiKey->spec()), + )->willReturn(3); for ($i = 0; $i < $count; $i++) { $adapter->getNbResults(); diff --git a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php index 047dbc96..ca3b463f 100644 --- a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php +++ b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php @@ -10,6 +10,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelper; @@ -39,12 +40,12 @@ class ShortCodeHelperTest extends TestCase $callIndex = 0; $expectedCalls = 3; $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUse('abc123', $expectedAuthority)->will( - function () use (&$callIndex, $expectedCalls) { - $callIndex++; - return $callIndex < $expectedCalls; - }, - ); + $shortCodeIsInUse = $repo->shortCodeIsInUseWithLock( + ShortUrlIdentifier::fromShortCodeAndDomain('abc123', $expectedAuthority), + )->will(function () use (&$callIndex, $expectedCalls) { + $callIndex++; + return $callIndex < $expectedCalls; + }); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->shortUrl->getDomain()->willReturn($domain); @@ -66,7 +67,9 @@ class ShortCodeHelperTest extends TestCase public function inUseSlugReturnsError(): void { $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUse('abc123', null)->willReturn(true); + $shortCodeIsInUse = $repo->shortCodeIsInUseWithLock( + ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), + )->willReturn(true); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->shortUrl->getDomain()->willReturn(null); diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index cf2330b3..73823729 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -46,12 +46,13 @@ class ShortUrlResolverTest extends TestCase { $shortUrl = ShortUrl::withLongUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); + $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); + $result = $this->urlResolver->resolveShortUrl($identifier, $apiKey); self::assertSame($shortUrl, $result); $findOne->shouldHaveBeenCalledOnce(); @@ -65,16 +66,17 @@ class ShortUrlResolverTest extends TestCase public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { $shortCode = 'abc123'; + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($shortCode, null, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); + $findOne = $repo->findOne($identifier, $apiKey !== null ? $apiKey->spec() : null)->willReturn(null); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); $this->expectException(ShortUrlNotFoundException::class); $findOne->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey); + $this->urlResolver->resolveShortUrl($identifier, $apiKey); } /** @test */ diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 024957b0..67420edc 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -124,7 +124,7 @@ class ShortUrlServiceTest extends TestCase 'maxVisits' => 10, 'longUrl' => 'modifiedLongUrl', ], - ), new ApiKey()]; + ), ApiKey::create()]; yield 'long URL with validation' => [1, ShortUrlEdit::fromRawData( [ 'longUrl' => 'modifiedLongUrl', diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 5f518184..33ae7be0 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagService; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; @@ -60,7 +61,7 @@ class TagServiceTest extends TestCase { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo($apiKey === null ? null : $apiKey->spec())->willReturn($expected); + $find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected); $result = $this->service->tagsInfo($apiKey); @@ -90,7 +91,10 @@ class TagServiceTest extends TestCase $this->expectExceptionMessage('You are not allowed to delete tags'); $delete->shouldNotBeCalled(); - $this->service->deleteTags(['foo', 'bar'], ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls())); + $this->service->deleteTags( + ['foo', 'bar'], + ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())), + ); } /** @test */ @@ -178,7 +182,7 @@ class TagServiceTest extends TestCase $this->service->renameTag( TagRenaming::fromNames('foo', 'bar'), - ApiKey::withRoles(RoleDefinition::forAuthoredShortUrls()), + ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())), ); } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 7e319314..6bac432e 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -46,7 +46,6 @@ class UrlShortenerTest extends TestCase return $callback(); }); $repo = $this->prophesize(ShortUrlRepository::class); - $repo->shortCodeIsInUse(Argument::cetera())->willReturn(false); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->shortCodeHelper = $this->prophesize(ShortCodeHelperInterface::class); diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 463ee1ef..aeef3f47 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -4,17 +4,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver; +use Doctrine\Common\EventManager; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\Persistence\ObjectRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use function count; + class PersistenceShortUrlRelationResolverTest extends TestCase { use ProphecyTrait; @@ -25,6 +28,8 @@ class PersistenceShortUrlRelationResolverTest extends TestCase public function setUp(): void { $this->em = $this->prophesize(EntityManagerInterface::class); + $this->em->getEventManager()->willReturn(new EventManager()); + $this->resolver = new PersistenceShortUrlRelationResolver($this->em->reveal()); } @@ -43,7 +48,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase */ public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void { - $repo = $this->prophesize(ObjectRepository::class); + $repo = $this->prophesize(DomainRepositoryInterface::class); $findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); $getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); @@ -66,10 +71,13 @@ class PersistenceShortUrlRelationResolverTest extends TestCase yield 'found domain' => [new Domain($authority), $authority]; } - /** @test */ - public function findsAndPersistsTagsWrappedIntoCollection(): void + /** + * @test + * @dataProvider provideTags + */ + public function findsAndPersistsTagsWrappedIntoCollection(array $tags, array $expectedTags): void { - $tags = ['foo', 'bar', 'baz']; + $expectedPersistedTags = count($expectedTags); $tagRepo = $this->prophesize(TagRepositoryInterface::class); $findTag = $tagRepo->findOneBy(Argument::type('array'))->will(function (array $args): ?Tag { @@ -81,11 +89,17 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $result = $this->resolver->resolveTags($tags); - self::assertCount(3, $result); - self::assertEquals([new Tag('foo'), new Tag('bar'), new Tag('baz')], $result->toArray()); - $findTag->shouldHaveBeenCalledTimes(3); + self::assertCount($expectedPersistedTags, $result); + self::assertEquals($expectedTags, $result->toArray()); + $findTag->shouldHaveBeenCalledTimes($expectedPersistedTags); $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes(3); + $persist->shouldHaveBeenCalledTimes($expectedPersistedTags); + } + + public function provideTags(): iterable + { + yield 'no duplicated tags' => [['foo', 'bar', 'baz'], [new Tag('foo'), new Tag('bar'), new Tag('baz')]]; + yield 'duplicated tags' => [['foo', 'bar', 'bar'], [new Tag('foo'), new Tag('bar')]]; } /** @test */ @@ -103,4 +117,45 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $getRepo->shouldNotHaveBeenCalled(); $persist->shouldNotHaveBeenCalled(); } + + /** @test */ + public function newDomainsAreMemoizedUntilStateIsCleared(): void + { + $repo = $this->prophesize(DomainRepositoryInterface::class); + $repo->findOneBy(Argument::type('array'))->willReturn(null); + $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $authority = 'foo.com'; + $domain1 = $this->resolver->resolveDomain($authority); + $domain2 = $this->resolver->resolveDomain($authority); + + self::assertSame($domain1, $domain2); + + $this->resolver->postFlush(); + $domain3 = $this->resolver->resolveDomain($authority); + + self::assertNotSame($domain1, $domain3); + } + + /** @test */ + public function newTagsAreMemoizedUntilStateIsCleared(): void + { + $tagRepo = $this->prophesize(TagRepositoryInterface::class); + $tagRepo->findOneBy(Argument::type('array'))->willReturn(null); + $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); + $this->em->persist(Argument::type(Tag::class))->will(function (): void { + }); + + $tags = ['foo', 'bar']; + [$foo1, $bar1] = $this->resolver->resolveTags($tags); + [$foo2, $bar2] = $this->resolver->resolveTags($tags); + + self::assertSame($foo1, $foo2); + self::assertSame($bar1, $bar2); + + $this->resolver->postFlush(); + [$foo3, $bar3] = $this->resolver->resolveTags($tags); + self::assertNotSame($foo1, $foo3); + self::assertNotSame($bar1, $bar3); + } } diff --git a/module/Core/test/Util/ApiKeyHelpersTrait.php b/module/Core/test/Util/ApiKeyHelpersTrait.php index 0b21ed5f..6624c8dd 100644 --- a/module/Core/test/Util/ApiKeyHelpersTrait.php +++ b/module/Core/test/Util/ApiKeyHelpersTrait.php @@ -11,6 +11,6 @@ trait ApiKeyHelpersTrait public function provideAdminApiKeys(): iterable { yield 'no API key' => [null]; - yield 'admin API key' => [new ApiKey()]; + yield 'admin API key' => [ApiKey::create()]; } } diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php index cf36c052..c836cd7c 100644 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -42,6 +42,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'date' => $visit->getDate()->toAtomString(), 'userAgent' => '', 'visitLocation' => null, + 'potentialBot' => false, 'visitedUrl' => '', 'type' => Visit::TYPE_BASE_URL, ], @@ -57,6 +58,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'date' => $visit->getDate()->toAtomString(), 'userAgent' => 'foo', 'visitLocation' => null, + 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', 'type' => Visit::TYPE_INVALID_SHORT_URL, ], @@ -68,12 +70,13 @@ class OrphanVisitDataTransformerTest extends TestCase ->withHeader('Referer', 'referer') ->withUri(new Uri('https://doma.in/foo/bar')), ), - )->locate($location = new VisitLocation(Location::emptyInstance())), + )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), [ 'referer' => 'referer', 'date' => $visit->getDate()->toAtomString(), 'userAgent' => 'user-agent', 'visitLocation' => $location, + 'potentialBot' => false, 'visitedUrl' => 'https://doma.in/foo/bar', 'type' => Visit::TYPE_REGULAR_404, ], diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index de2a3534..cae3fbb1 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -10,7 +10,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; @@ -23,6 +22,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; @@ -53,7 +54,9 @@ class VisitsStatsHelperTest extends TestCase { $repo = $this->prophesize(VisitRepository::class); $count = $repo->countVisits(null)->willReturn($expectedCount * 3); - $countOrphan = $repo->countOrphanVisits()->willReturn($expectedCount); + $countOrphan = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn( + $expectedCount, + ); $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); $stats = $this->helper->getVisitsStats(); @@ -76,20 +79,22 @@ class VisitsStatsHelperTest extends TestCase public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { $shortCode = '123ABC'; + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $spec = $apiKey === null ? null : $apiKey->spec(); + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null, $spec)->willReturn(true); + $count = $repo->shortCodeIsInUse($identifier, $spec)->willReturn( + true, + ); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0, $spec)->willReturn( - $list, - ); - $repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), $spec)->willReturn(1); + $repo2->findVisitsByShortCode($identifier, Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByShortCode($identifier, Argument::type(VisitsCountFiltering::class))->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); - $paginator = $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams(), $apiKey); + $paginator = $this->helper->visitsForShortUrl($identifier, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); $count->shouldHaveBeenCalledOnce(); @@ -99,21 +104,25 @@ class VisitsStatsHelperTest extends TestCase public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void { $shortCode = '123ABC'; + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); + $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($shortCode, null, null)->willReturn(false); + $count = $repo->shortCodeIsInUse($identifier, null)->willReturn( + false, + ); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); $this->expectException(ShortUrlNotFoundException::class); $count->shouldBeCalledOnce(); - $this->helper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams()); + $this->helper->visitsForShortUrl($identifier, new VisitsParams()); } /** @test */ public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void { $tag = 'foo'; - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $repo = $this->prophesize(TagRepository::class); $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); @@ -136,11 +145,10 @@ class VisitsStatsHelperTest extends TestCase $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); - $spec = $apiKey === null ? null : $apiKey->spec(); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0, $spec)->willReturn($list); - $repo2->countVisitsByTag($tag, Argument::type(DateRange::class), $spec)->willReturn(1); + $repo2->findVisitsByTag($tag, Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByTag($tag, Argument::type(VisitsCountFiltering::class))->willReturn(1); $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); $paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); @@ -155,8 +163,8 @@ class VisitsStatsHelperTest extends TestCase { $list = map(range(0, 3), fn () => Visit::forBasePath(Visitor::emptyInstance())); $repo = $this->prophesize(VisitRepository::class); - $countVisits = $repo->countOrphanVisits(Argument::type(DateRange::class))->willReturn(count($list)); - $listVisits = $repo->findOrphanVisits(Argument::type(DateRange::class), Argument::cetera())->willReturn($list); + $countVisits = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(count($list)); + $listVisits = $repo->findOrphanVisits(Argument::type(VisitsListFiltering::class))->willReturn($list); $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); $paginator = $this->helper->orphanVisits(new VisitsParams()); diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 118ebc06..45188f6c 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Visit\VisitsTracker; class VisitsTrackerTest extends TestCase @@ -24,13 +24,18 @@ class VisitsTrackerTest extends TestCase private VisitsTracker $visitsTracker; private ObjectProphecy $em; private ObjectProphecy $eventDispatcher; - private UrlShortenerOptions $options; + private TrackingOptions $options; public function setUp(): void { $this->em = $this->prophesize(EntityManager::class); + $this->em->transactional(Argument::any())->will(function (array $args) { + [$callback] = $args; + return $callback(); + }); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - $this->options = new UrlShortenerOptions(); + $this->options = new TrackingOptions(); $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options); } @@ -41,14 +46,33 @@ class VisitsTrackerTest extends TestCase */ public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void { - $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce(); - $this->em->flush()->shouldBeCalledOnce(); + $persist = $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->will(function (): void { + }); $this->visitsTracker->{$method}(...$args); + $persist->shouldHaveBeenCalledOnce(); + $this->em->transactional(Argument::cetera())->shouldHaveBeenCalledOnce(); + $this->em->flush()->shouldHaveBeenCalledOnce(); $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); } + /** + * @test + * @dataProvider provideTrackingMethodNames + */ + public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void + { + $this->options->disableTracking = true; + + $this->visitsTracker->{$method}(...$args); + + $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->transactional(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->flush()->shouldNotHaveBeenCalled(); + } + public function provideTrackingMethodNames(): iterable { yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; @@ -68,6 +92,7 @@ class VisitsTrackerTest extends TestCase $this->visitsTracker->{$method}(Visitor::emptyInstance()); $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->em->transactional(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); $this->em->flush()->shouldNotHaveBeenCalled(); } diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 95f53b30..63716e74 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -28,6 +28,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->unique() ->build(); + $builder->createField('name', Types::STRING) + ->columnName('`name`') + ->nullable() + ->build(); + $builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME) ->columnName('expiration_date') ->nullable() diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php new file mode 100644 index 00000000..aa3c117a --- /dev/null +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -0,0 +1,60 @@ +name = $name; + $this->expirationDate = $expirationDate; + $this->roleDefinitions = $roleDefinitions; + } + + public static function withName(string $name): self + { + return new self($name, null, []); + } + + public static function withExpirationDate(Chronos $expirationDate): self + { + return new self(null, $expirationDate, []); + } + + public static function withNameAndExpirationDate(string $name, Chronos $expirationDate): self + { + return new self($name, $expirationDate, []); + } + + public static function withRoles(RoleDefinition ...$roleDefinitions): self + { + return new self(null, null, $roleDefinitions); + } + + public function name(): ?string + { + return $this->name; + } + + public function expirationDate(): ?Chronos + { + return $this->expirationDate; + } + + /** + * @return RoleDefinition[] + */ + public function roleDefinitions(): array + { + return $this->roleDefinitions; + } +} diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index ff3211ba..c3677029 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -21,15 +21,18 @@ class Role self::DOMAIN_SPECIFIC => 'Domain only', ]; - public static function toSpec(ApiKeyRole $role, bool $inlined): Specification + public static function toSpec(ApiKeyRole $role, bool $inlined, ?string $context = null): Specification { if ($role->name() === self::AUTHORED_SHORT_URLS) { - return $inlined ? new BelongsToApiKeyInlined($role->apiKey()) : new BelongsToApiKey($role->apiKey()); + $apiKey = $role->apiKey(); + return $inlined ? Spec::andX(new BelongsToApiKeyInlined($apiKey)) : new BelongsToApiKey($apiKey, $context); } if ($role->name() === self::DOMAIN_SPECIFIC) { $domainId = self::domainIdFromMeta($role->meta()); - return $inlined ? new BelongsToDomainInlined($domainId) : new BelongsToDomain($domainId); + return $inlined + ? Spec::andX(new BelongsToDomainInlined($domainId)) + : new BelongsToDomain($domainId, $context); } return Spec::andX(); diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 64359d15..a1f9b361 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\ApiKey\Spec; -use Happyr\DoctrineSpecification\BaseSpecification; use Happyr\DoctrineSpecification\Spec; +use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -25,7 +25,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification { return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( Spec::join($this->fieldToJoin, 's'), - $this->apiKey->spec(), + $this->apiKey->spec(false, $this->fieldToJoin), ); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 62729031..0317390e 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -12,6 +12,7 @@ use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Ramsey\Uuid\Uuid; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Role; @@ -22,33 +23,31 @@ class ApiKey extends AbstractEntity private bool $enabled; /** @var Collection|ApiKeyRole[] */ private Collection $roles; + private ?string $name; /** * @throws Exception */ - public function __construct(?Chronos $expirationDate = null) + private function __construct(?string $name = null, ?Chronos $expirationDate = null) { $this->key = Uuid::uuid4()->toString(); $this->expirationDate = $expirationDate; + $this->name = $name; $this->enabled = true; $this->roles = new ArrayCollection(); } - public static function withRoles(RoleDefinition ...$roleDefinitions): self + public static function create(): ApiKey { - $apiKey = new self(); - - foreach ($roleDefinitions as $roleDefinition) { - $apiKey->registerRole($roleDefinition); - } - - return $apiKey; + return new self(); } - public static function withKey(string $key, ?Chronos $expirationDate = null): self + public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($expirationDate); - $apiKey->key = $key; + $apiKey = new self($meta->name(), $meta->expirationDate()); + foreach ($meta->roleDefinitions() as $roleDefinition) { + $apiKey->registerRole($roleDefinition); + } return $apiKey; } @@ -63,6 +62,11 @@ class ApiKey extends AbstractEntity return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now()); } + public function name(): ?string + { + return $this->name; + } + public function isEnabled(): bool { return $this->enabled; @@ -92,9 +96,9 @@ class ApiKey extends AbstractEntity return $this->key; } - public function spec(bool $inlined = false): Specification + public function spec(bool $inlined = false, ?string $context = null): Specification { - $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined))->getValues(); + $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined, $context))->getValues(); return Spec::andX(...$specs); } diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php index 917cf048..e81c446f 100644 --- a/module/Rest/src/Service/ApiKeyService.php +++ b/module/Rest/src/Service/ApiKeyService.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Rest\Service; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,9 +22,12 @@ class ApiKeyService implements ApiKeyServiceInterface $this->em = $em; } - public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey - { - $key = new ApiKey($expirationDate); + public function create( + ?Chronos $expirationDate = null, + ?string $name = null, + RoleDefinition ...$roleDefinitions + ): ApiKey { + $key = $this->buildApiKeyWithParams($expirationDate, $name); foreach ($roleDefinitions as $definition) { $key->registerRole($definition); } @@ -34,6 +38,24 @@ class ApiKeyService implements ApiKeyServiceInterface return $key; } + private function buildApiKeyWithParams(?Chronos $expirationDate, ?string $name): ApiKey + { + // TODO Use match expression when migrating to PHP8 + if ($expirationDate === null && $name === null) { + return ApiKey::create(); + } + + if ($expirationDate !== null && $name !== null) { + return ApiKey::fromMeta(ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate)); + } + + if ($name === null) { + return ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)); + } + + return ApiKey::fromMeta(ApiKeyMeta::withName($name)); + } + public function check(string $key): ApiKeyCheckResult { $apiKey = $this->getByKey($key); diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index 562f106b..982bdf4f 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -11,7 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ApiKeyServiceInterface { - public function create(?Chronos $expirationDate = null, RoleDefinition ...$roleDefinitions): ApiKey; + public function create( + ?Chronos $expirationDate = null, + ?string $name = null, + RoleDefinition ...$roleDefinitions + ): ApiKey; public function check(string $key): ApiKeyCheckResult; diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index f81524ae..95d77dc6 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -26,6 +26,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => null, 'title' => 'My cool title', + 'crawlable' => true, ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', @@ -41,6 +42,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => null, 'title' => null, + 'crawlable' => false, ]; private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [ 'shortCode' => 'custom-with-domain', @@ -56,6 +58,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => 'some-domain.com', 'title' => null, + 'crawlable' => false, ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', @@ -73,6 +76,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => null, 'title' => null, + 'crawlable' => false, ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', @@ -88,6 +92,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => null, 'title' => null, + 'crawlable' => false, ]; private const SHORT_URL_CUSTOM_DOMAIN = [ 'shortCode' => 'ghi789', @@ -105,6 +110,7 @@ class ListShortUrlsTest extends ApiTestCase ], 'domain' => 'example.com', 'title' => null, + 'crawlable' => false, ]; /** diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index ea890f9f..067cf9a4 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -12,17 +12,18 @@ class OrphanVisitsTest extends ApiTestCase private const INVALID_SHORT_URL = [ 'referer' => 'https://doma.in/foo', 'date' => '2020-03-01T00:00:00+00:00', - 'userAgent' => 'shlink-tests-agent', + 'userAgent' => 'cf-facebook', 'visitLocation' => null, + 'potentialBot' => true, 'visitedUrl' => 'foo.com', 'type' => 'invalid_short_url', - ]; private const REGULAR_NOT_FOUND = [ 'referer' => 'https://doma.in/foo/bar', 'date' => '2020-02-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, + 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'regular_404', ]; @@ -31,6 +32,7 @@ class OrphanVisitsTest extends ApiTestCase 'date' => '2020-01-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, + 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'base_url', ]; @@ -39,21 +41,32 @@ class OrphanVisitsTest extends ApiTestCase * @test * @dataProvider provideQueries */ - public function properVisitsAreReturnedBasedInQuery(array $query, int $expectedAmount, array $expectedVisits): void - { + public function properVisitsAreReturnedBasedInQuery( + array $query, + int $totalItems, + int $expectedAmount, + array $expectedVisits + ): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [RequestOptions::QUERY => $query]); $payload = $this->getJsonResponsePayload($resp); $visits = $payload['visits']['data'] ?? []; - self::assertEquals(3, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertEquals($totalItems, $payload['visits']['pagination']['totalItems'] ?? -1); self::assertCount($expectedAmount, $visits); self::assertEquals($expectedVisits, $visits); } public function provideQueries(): iterable { - yield 'all data' => [[], 3, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND, self::BASE_URL]]; - yield 'limit items' => [['itemsPerPage' => 2], 2, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND]]; - yield 'limit items and page' => [['itemsPerPage' => 2, 'page' => 2], 1, [self::BASE_URL]]; + yield 'all data' => [[], 3, 3, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND, self::BASE_URL]]; + yield 'limit items' => [['itemsPerPage' => 2], 3, 2, [self::INVALID_SHORT_URL, self::REGULAR_NOT_FOUND]]; + yield 'limit items and page' => [['itemsPerPage' => 2, 'page' => 2], 3, 1, [self::BASE_URL]]; + yield 'exclude bots' => [['excludeBots' => true], 2, 2, [self::REGULAR_NOT_FOUND, self::BASE_URL]]; + yield 'exclude bots and limit items' => [ + ['excludeBots' => true, 'itemsPerPage' => 1], + 2, + 1, + [self::REGULAR_NOT_FOUND], + ]; } } diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index c578d48d..1d572004 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -67,4 +67,30 @@ class ShortUrlVisitsTest extends ApiTestCase yield 'domain' => ['example.com', 0]; yield 'no domain' => [null, 2]; } + + /** + * @test + * @dataProvider provideVisitsForBots + */ + public function properVisitsAreReturnedWhenExcludingBots(bool $excludeBots, int $expectedAmountOfVisits): void + { + $shortCode = 'def456'; + $url = new Uri(sprintf('/short-urls/%s/visits', $shortCode)); + + if ($excludeBots) { + $url = $url->withQuery(Query::build(['excludeBots' => true])); + } + + $resp = $this->callApiWithKey(self::METHOD_GET, (string) $url); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []); + } + + public function provideVisitsForBots(): iterable + { + yield 'bots excluded' => [true, 1]; + yield 'bots not excluded' => [false, 2]; + } } diff --git a/module/Rest/test-api/Action/TagVisitsTest.php b/module/Rest/test-api/Action/TagVisitsTest.php index b30b787f..07b0576d 100644 --- a/module/Rest/test-api/Action/TagVisitsTest.php +++ b/module/Rest/test-api/Action/TagVisitsTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; +use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function sprintf; @@ -14,9 +15,15 @@ class TagVisitsTest extends ApiTestCase * @test * @dataProvider provideTags */ - public function expectedVisitsAreReturned(string $apiKey, string $tag, int $expectedVisitsAmount): void - { - $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [], $apiKey); + public function expectedVisitsAreReturned( + string $apiKey, + string $tag, + bool $excludeBots, + int $expectedVisitsAmount + ): void { + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag), [ + RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [], + ], $apiKey); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); @@ -27,12 +34,16 @@ class TagVisitsTest extends ApiTestCase public function provideTags(): iterable { - yield 'foo with admin API key' => ['valid_api_key', 'foo', 5]; - yield 'bar with admin API key' => ['valid_api_key', 'bar', 2]; - yield 'baz with admin API key' => ['valid_api_key', 'baz', 0]; - yield 'foo with author API key' => ['author_api_key', 'foo', 5]; - yield 'bar with author API key' => ['author_api_key', 'bar', 2]; - yield 'foo with domain API key' => ['domain_api_key', 'foo', 0]; + yield 'foo with admin API key' => ['valid_api_key', 'foo', false, 5]; + yield 'foo with admin API key and no bots' => ['valid_api_key', 'foo', true, 4]; + yield 'bar with admin API key' => ['valid_api_key', 'bar', false, 2]; + yield 'bar with admin API key and no bots' => ['valid_api_key', 'bar', true, 1]; + yield 'baz with admin API key' => ['valid_api_key', 'baz', false, 0]; + yield 'foo with author API key' => ['author_api_key', 'foo', false, 5]; + yield 'foo with author API key and no bots' => ['author_api_key', 'foo', true, 4]; + yield 'bar with author API key' => ['author_api_key', 'bar', false, 2]; + yield 'bar with author API key and no bots' => ['author_api_key', 'bar', true, 1]; + yield 'foo with domain API key' => ['domain_api_key', 'foo', false, 0]; } /** diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index c6383968..ef6d1781 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -8,7 +8,9 @@ use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; +use ReflectionObject; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -41,7 +43,11 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey { - $apiKey = ApiKey::withKey($key, $expiresAt); + $apiKey = $expiresAt !== null ? ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expiresAt)) : ApiKey::create(); + $ref = new ReflectionObject($apiKey); + $keyProp = $ref->getProperty('key'); + $keyProp->setAccessible(true); + $keyProp->setValue($apiKey, $key); if (! $enabled) { $apiKey->disable(); diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index bfc65aa0..ccc83525 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -35,6 +35,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'longUrl' => 'https://shlink.io', 'tags' => ['foo'], 'title' => 'My cool title', + 'crawlable' => true, ]), $relationResolver), '2018-05-01', ); diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 412c79d5..4432df92 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -36,7 +36,7 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); $manager->persist( - Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1', '')), + Visit::forValidShortUrl($defShortUrl, new Visitor('cf-facebook', '', '127.0.0.1', '')), ); $manager->persist( Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), @@ -58,7 +58,7 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface '2020-02-01', )); $manager->persist($this->setVisitDate( - Visit::forInvalidShortUrl(new Visitor('shlink-tests-agent', 'https://doma.in/foo', '1.2.3.4', 'foo.com')), + Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://doma.in/foo', '1.2.3.4', 'foo.com')), '2020-03-01', )); diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index d6dcc4a3..cbe43895 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -30,7 +30,7 @@ class ListDomainsActionTest extends TestCase /** @test */ public function domainsAreProperlyListed(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $domains = [ new DomainItem('bar.com', true), new DomainItem('baz.com', false), diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index f8e95659..ffcd6c62 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -40,7 +40,7 @@ class CreateShortUrlActionTest extends TestCase /** @test */ public function properShortcodeConversionReturnsData(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $shortUrl = ShortUrl::createEmpty(); $expectedMeta = $body = [ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -80,7 +80,7 @@ class CreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', 'domain' => $domain, - ])->withAttribute(ApiKey::class, new ApiKey()); + ])->withAttribute(ApiKey::class, ApiKey::create()); $this->expectException(ValidationException::class); $urlToShortCode->shouldNotBeCalled(); diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index 9be06756..9705cd59 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -29,7 +29,7 @@ class DeleteShortUrlActionTest extends TestCase /** @test */ public function emptyResponseIsReturnedIfProperlyDeleted(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $deleteByShortCode = $this->service->deleteByShortCode(Argument::any(), false, $apiKey)->will( function (): void { }, diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index eee75dbf..e1f434df 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -48,7 +48,7 @@ class EditShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $request = (new ServerRequest())->withAttribute('shortCode', 'abc123') - ->withAttribute(ApiKey::class, new ApiKey()) + ->withAttribute(ApiKey::class, ApiKey::create()) ->withParsedBody([ 'maxVisits' => 5, ]); diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index a345046a..59c55d84 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -58,6 +58,6 @@ class EditShortUrlTagsActionTest extends TestCase private function createRequestWithAPiKey(): ServerRequestInterface { - return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); } } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 2683b514..712d605d 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -51,7 +51,7 @@ class ListShortUrlsActionTest extends TestCase ?string $startDate = null, ?string $endDate = null ): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey); $listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([ 'page' => $expectedPage, diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 748ab642..6f8ddbb9 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -37,7 +37,7 @@ class ResolveShortUrlActionTest extends TestCase public function correctShortCodeReturnsSuccess(): void { $shortCode = 'abc123'; - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( ShortUrl::withLongUrl('http://domain.com/foo/bar'), )->shouldBeCalledOnce(); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index f78a9de5..8bb1482a 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -39,7 +39,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase /** @test */ public function properDataIsPassedWhenGeneratingShortCode(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $request = (new ServerRequest())->withQueryParams([ 'longUrl' => 'http://foobar.com', diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 957c01a5..4812649d 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -34,7 +34,7 @@ class DeleteTagsActionTest extends TestCase { $request = (new ServerRequest()) ->withQueryParams(['tags' => $tags]) - ->withAttribute(ApiKey::class, new ApiKey()); + ->withAttribute(ApiKey::class, ApiKey::create()); $deleteTags = $this->tagService->deleteTags($tags ?: [], Argument::type(ApiKey::class)); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 9bdad15b..8b7378fd 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -83,6 +83,6 @@ class ListTagsActionTest extends TestCase private function requestWithApiKey(): ServerRequestInterface { - return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); } } diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 681e68f6..d7b398db 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -70,6 +70,6 @@ class UpdateTagActionTest extends TestCase private function requestWithApiKey(): ServerRequestInterface { - return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); } } diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index d53cb20d..829b820b 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -30,7 +30,7 @@ class GlobalVisitsActionTest extends TestCase /** @test */ public function statsAreReturnedFromHelper(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $stats = new VisitsStats(5, 3); $getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats); diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 6b149877..d0c67e7c 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -73,6 +73,6 @@ class ShortUrlVisitsActionTest extends TestCase private function requestWithApiKey(): ServerRequestInterface { - return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, new ApiKey()); + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); } } diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index da046f26..be3ce914 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -33,7 +33,7 @@ class TagVisitsActionTest extends TestCase public function providingCorrectShortCodeReturnsVisits(): void { $tag = 'foo'; - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( new Paginator(new ArrayAdapter([])), ); diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index 4cb9ba1b..278d37ff 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -28,14 +28,14 @@ class RoleTest extends TestCase public function provideRoles(): iterable { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); yield 'inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), true, Spec::andX()]; yield 'not inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), false, Spec::andX()]; yield 'inline author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), true, - new BelongsToApiKeyInlined($apiKey), + Spec::andX(new BelongsToApiKeyInlined($apiKey)), ]; yield 'not inline author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), @@ -45,7 +45,7 @@ class RoleTest extends TestCase yield 'inline domain role' => [ new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '123'], $apiKey), true, - new BelongsToDomainInlined('123'), + Spec::andX(new BelongsToDomainInlined('123')), ]; yield 'not inline domain role' => [ new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '456'], $apiKey), diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 2edbe5e6..68503b58 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -138,7 +138,7 @@ class AuthenticationMiddlewareTest extends TestCase /** @test */ public function validApiKeyFallsBackToNextMiddleware(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $key = $apiKey->toString(); $request = ServerRequestFactory::fromGlobals() ->withAttribute( diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 6879d492..addebbcd 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -13,6 +13,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -35,14 +36,15 @@ class ApiKeyServiceTest extends TestCase * @dataProvider provideCreationDate * @param RoleDefinition[] $roles */ - public function apiKeyIsProperlyCreated(?Chronos $date, array $roles): void + public function apiKeyIsProperlyCreated(?Chronos $date, ?string $name, array $roles): void { $this->em->flush()->shouldBeCalledOnce(); $this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce(); - $key = $this->service->create($date, ...$roles); + $key = $this->service->create($date, $name, ...$roles); self::assertEquals($date, $key->getExpirationDate()); + self::assertEquals($name, $key->name()); foreach ($roles as $roleDefinition) { self::assertTrue($key->hasRole($roleDefinition->roleName())); } @@ -50,12 +52,15 @@ class ApiKeyServiceTest extends TestCase public function provideCreationDate(): iterable { - yield 'no expiration date' => [null, []]; - yield 'expiration date' => [Chronos::parse('2030-01-01'), []]; - yield 'roles' => [null, [ + yield 'no expiration date or name' => [null, null, []]; + yield 'expiration date' => [Chronos::parse('2030-01-01'), null, []]; + yield 'roles' => [null, null, [ RoleDefinition::forDomain((new Domain(''))->setId('123')), RoleDefinition::forAuthoredShortUrls(), ]]; + yield 'single name' => [null, 'Alice', []]; + yield 'multi-word name' => [null, 'Alice and Bob', []]; + yield 'empty name' => [null, '', []]; } /** @@ -78,14 +83,14 @@ class ApiKeyServiceTest extends TestCase public function provideInvalidApiKeys(): iterable { yield 'non-existent api key' => [null]; - yield 'disabled api key' => [(new ApiKey())->disable()]; - yield 'expired api key' => [new ApiKey(Chronos::now()->subDay())]; + yield 'disabled api key' => [ApiKey::create()->disable()]; + yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::withExpirationDate(Chronos::now()->subDay()))]; } /** @test */ public function checkReturnsTrueWhenConditionsAreFavorable(): void { - $apiKey = new ApiKey(); + $apiKey = ApiKey::create(); $repo = $this->prophesize(EntityRepository::class); $repo->findOneBy(['key' => '12345'])->willReturn($apiKey) @@ -114,7 +119,7 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function disableReturnsDisabledApiKeyWhenFound(): void { - $key = new ApiKey(); + $key = ApiKey::create(); $repo = $this->prophesize(EntityRepository::class); $repo->findOneBy(['key' => '12345'])->willReturn($key) ->shouldBeCalledOnce(); @@ -131,7 +136,7 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function listFindsAllApiKeys(): void { - $expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()]; + $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $repo = $this->prophesize(EntityRepository::class); $repo->findBy([])->willReturn($expectedApiKeys) @@ -146,7 +151,7 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function listEnabledFindsOnlyEnabledApiKeys(): void { - $expectedApiKeys = [new ApiKey(), new ApiKey(), new ApiKey()]; + $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $repo = $this->prophesize(EntityRepository::class); $repo->findBy(['enabled' => true])->willReturn($expectedApiKeys) diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index dfbc662a..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -# For more information about the robots.txt standard, see: -# http://www.robotstxt.org/orig.html - -User-agent: * -Disallow: /