diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 0a8a09d7..78cbdf1c 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -41,10 +41,7 @@ runs: extensions: ${{ inputs.php-extensions }} coverage: pcov ini-values: pcov.directory=module - - run: echo "::set-output name=composerArgs::${{ inputs.php-version == '8.2' && '--ignore-platform-req=php' || '' }}" - id: composer_args - shell: bash - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }} + run: composer install --no-interaction --prefer-dist shell: bash diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 50060383..db3efacf 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] - continue-on-error: ${{ matrix.php-version == '8.2' }} env: LC_ALL: C steps: @@ -28,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 + php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index c7b361d8..ac510c7d 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -14,13 +14,12 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] - continue-on-error: ${{ matrix.php-version == '8.2' }} steps: - uses: actions/checkout@v3 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index e20428c6..f7e7b141 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] - continue-on-error: ${{ matrix.php-version == '8.2' }} steps: - uses: actions/checkout@v3 - name: Start postgres database server @@ -26,7 +25,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013226a6..ca34c07d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -44,7 +44,6 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] - continue-on-error: ${{ matrix.php-version == '8.2' }} steps: - uses: actions/checkout@v3 - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres @@ -52,10 +51,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - - run: echo "::set-output name=composerArgs::${{ matrix.php-version == '8.2' && '--ignore-platform-req=php' || '' }}" - id: composer_args - shell: bash - - run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }} + - run: composer install --no-interaction --prefer-dist - run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr - run: composer test:api:rr diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index 96457033..9eb682d6 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -8,7 +8,7 @@ on: - 'v*' jobs: - build-openswool: + build-openswoole: uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main secrets: inherit with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b4ed7bba..792513be 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,14 +10,14 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] + php-version: ['8.1', '8.2'] swoole: ['yes', 'no'] steps: - uses: actions/checkout@v3 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.1'] + php-version: ['8.1', '8.2'] swoole: ['yes', 'no'] steps: - uses: geekyeggo/delete-artifact@v1 diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 9002353d..6e6cb925 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a8462b20..c3b9b2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.4.0] - 2022-12-16 +### Added +* [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits. + + This can be done by: + + * Providing `excludeMaxVisitsReached=true` and/or `excludePastValidUntil=true` to the `GET /short-urls` endpoint. + * Providing `--exclude-max-visits-reached` and/or `--exclude-past-valid-until` to the `short-urls:list` command. + +* [#1613](https://github.com/shlinkio/shlink/issues/1613) Added amount of visits coming from bots, non-bots and total to every short URL in the short URLs list. + + Additionally, added option to order by non-bot visits, by passing `nonBotVisits-DESC` or `nonBotVisits-ASC`. + +* [#1599](https://github.com/shlinkio/shlink/issues/1599) Added support for credentials on redis DSNs, either only password, or both username and password. +* [#1616](https://github.com/shlinkio/shlink/issues/1616) Added support to import orphan visits when importing short URLs from another Shlink instance. +* [#1519](https://github.com/shlinkio/shlink/issues/1519) Allowing to search short URLs by default domain. +* [#1555](https://github.com/shlinkio/shlink/issues/1555) and [#1625](https://github.com/shlinkio/shlink/issues/1625) Added full support for PHP 8.2, updating the docker image to this version. + +### Changed +* [#1563](https://github.com/shlinkio/shlink/issues/1563) Moved logic to reuse command options to option classes instead of base abstract command classes. +* [#1569](https://github.com/shlinkio/shlink/issues/1569) Migrated test doubles from phpspec/prophecy to PHPUnit mocks. +* [#1329](https://github.com/shlinkio/shlink/issues/1329) Split some logic from `VisitRepository` and `ShortUrlRepository` into separated repository classes. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1618](https://github.com/shlinkio/shlink/issues/1618) Fixed imported short URLs and visits dates not being set to the target server timezone. +* [#1578](https://github.com/shlinkio/shlink/issues/1578) Fixed short URL allowing an empty string as the domain during creation. +* [#1580](https://github.com/shlinkio/shlink/issues/1580) Fixed `FLUSHDB` being run on Shlink docker start-up when using redis, causing full cache to be flushed. + + ## [3.3.2] - 2022-10-18 ### Added * *Nothing* diff --git a/Dockerfile b/Dockerfile index 2835d75f..8c38653b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -FROM php:8.1.9-alpine3.16 as base +FROM php:8.2-alpine3.17 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=openswoole ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ENV OPENSWOOLE_VERSION 4.11.1 +ENV OPENSWOOLE_VERSION 4.12.0 ENV PDO_SQLSRV_VERSION 5.10.1 -ENV MS_ODBC_SQL_VERSION 17.5.2.2 +ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' +ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV LC_ALL "C" WORKDIR /etc/shlink @@ -14,7 +15,7 @@ WORKDIR /etc/shlink # Install required PHP extensions RUN \ # Temp install dev dependencies needed to compile the extensions - apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev && \ + apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev linux-headers && \ docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \ apk add --no-cache sqlite-libs && \ docker-php-ext-install -j"$(nproc)" pdo_sqlite && \ @@ -29,11 +30,11 @@ RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ docker-php-ext-enable openswoole ; \ fi; \ if [ $(uname -m) == "x86_64" ]; then \ - wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \ docker-php-ext-enable pdo_sqlsrv && \ - rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \ + rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \ fi; \ apk del .phpize-deps diff --git a/README.md b/README.md index bb99634e..c6dfa953 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio) +[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain. diff --git a/composer.json b/composer.json index 6854d3fc..ddf41fa5 100644 --- a/composer.json +++ b/composer.json @@ -21,38 +21,38 @@ "cakephp/chronos": "^2.3", "doctrine/migrations": "^3.5", "doctrine/orm": "^2.13.3", - "endroid/qr-code": "^4.4", - "geoip2/geoip2": "^2.12", - "guzzlehttp/guzzle": "^7.4", + "endroid/qr-code": "^4.6", + "geoip2/geoip2": "^2.13", + "guzzlehttp/guzzle": "^7.5", "happyr/doctrine-specification": "^2.0", - "jaybizzle/crawler-detect": "^1.2.110", + "jaybizzle/crawler-detect": "^1.2.112", "laminas/laminas-config": "^3.7", - "laminas/laminas-config-aggregator": "^1.8", - "laminas/laminas-diactoros": "^2.14", - "laminas/laminas-inputfilter": "^2.19", - "laminas/laminas-servicemanager": "^3.16", - "laminas/laminas-stdlib": "^3.11", - "lcobucci/jwt": "^4.1", - "league/uri": "^6.7", + "laminas/laminas-config-aggregator": "^1.11", + "laminas/laminas-diactoros": "^2.19", + "laminas/laminas-inputfilter": "^2.22", + "laminas/laminas-servicemanager": "^3.19", + "laminas/laminas-stdlib": "^3.15", + "lcobucci/jwt": "^4.2", + "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.11", - "mezzio/mezzio-fastroute": "^3.5", - "mezzio/mezzio-problem-details": "^1.6", - "mezzio/mezzio-swoole": "^4.3", + "mezzio/mezzio": "^3.13", + "mezzio/mezzio-fastroute": "^3.7", + "mezzio/mezzio-problem-details": "^1.7", + "mezzio/mezzio-swoole": "^4.5", "mlocati/ip-lib": "^1.18", "ocramius/proxy-manager": "^2.14", "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", - "pugx/shortid-php": "^1.0", - "ramsey/uuid": "^4.3", - "shlinkio/shlink-common": "^5.1", - "shlinkio/shlink-config": "^2.1", + "pugx/shortid-php": "^1.1", + "ramsey/uuid": "^4.5", + "shlinkio/shlink-common": "^5.2", + "shlinkio/shlink-config": "^2.3", "shlinkio/shlink-event-dispatcher": "^2.6", - "shlinkio/shlink-importer": "^4.0", + "shlinkio/shlink-importer": "^5.0", "shlinkio/shlink-installer": "^8.2", - "shlinkio/shlink-ip-geolocation": "^3.1", + "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", - "spiral/roadrunner-jobs": "^2.3", + "spiral/roadrunner-jobs": "^2.5", "symfony/console": "^6.1", "symfony/filesystem": "^6.1", "symfony/lock": "^6.1", @@ -64,10 +64,10 @@ "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.4.0", "infection/infection": "^0.26.15", - "openswoole/ide-helper": "~4.11.1", - "phpspec/prophecy-phpunit": "^2.0", + "openswoole/ide-helper": "~4.11.5", "phpstan/phpstan": "^1.8", "phpstan/phpstan-doctrine": "^1.3", + "phpstan/phpstan-phpunit": "^1.1", "phpstan/phpstan-symfony": "^1.2", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", @@ -109,7 +109,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/config config docker/config data/migrations --level=8", + "stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8", "test": [ "@parallel test:unit test:db", "@parallel test:api test:cli" @@ -132,10 +132,10 @@ "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", "test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli", "infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84", + "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.json5", "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5", - "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=80 --configuration=infection-cli.json5", + "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", "infect:test": [ "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php new file mode 100644 index 00000000..614b140f --- /dev/null +++ b/config/autoload/cache.global.php @@ -0,0 +1,24 @@ +loadFromEnv(); + $redis = ['pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false)]; + $cacheRedisBlock = $redisServers === null ? [] : [ + 'redis' => [ + 'servers' => $redisServers, + 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), + ], + ]; + + return [ + 'cache' => [ + 'namespace' => 'Shlink', + ...$cacheRedisBlock, + ], + 'redis' => $redis, + ]; +})(); diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 5a75ca6b..58899217 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -42,6 +42,9 @@ return (static function (): array { 'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()), 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null, 'charset' => $resolveCharset(), + 'driverOptions' => $driver !== 'mssql' ? [] : [ + 'TrustServerCertificate' => 'true', + ], ], }; diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php deleted file mode 100644 index 1d035055..00000000 --- a/config/autoload/redis.global.php +++ /dev/null @@ -1,27 +0,0 @@ -loadFromEnv(); - $pubSub = [ - 'redis' => [ - 'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false), - ], - ]; - - return match ($redisServers) { - null => $pubSub, - default => [ - 'cache' => [ - 'redis' => [ - 'servers' => $redisServers, - 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), - ], - ], - ...$pubSub, - ], - }; -})(); diff --git a/config/autoload/redis.local.php.local b/config/autoload/redis.local.php.local index 9bd8fea6..7fd57112 100644 --- a/config/autoload/redis.local.php.local +++ b/config/autoload/redis.local.php.local @@ -7,6 +7,8 @@ return [ 'cache' => [ 'redis' => [ 'servers' => 'tcp://shlink_redis:6379', +// 'servers' => 'tcp://barbar@shlink_redis_acl:6379', +// 'servers' => 'tcp://foo:bar@shlink_redis_acl:6379', ], ], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 678e1b05..368a5f4e 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -101,6 +101,9 @@ $buildDbConnection = static function (): array { 'user' => 'sa', 'password' => 'Passw0rd!', 'dbname' => 'shlink_test', + 'driverOptions' => [ + 'TrustServerCertificate' => 'true', + ], ], default => [ // mysql and maria 'driver' => 'pdo_mysql', diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index a2066752..90ccab23 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,9 +1,10 @@ -FROM php:8.1.9-fpm-alpine3.16 +FROM php:8.2-fpm-alpine3.17 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV PDO_SQLSRV_VERSION 5.10.1 -ENV MS_ODBC_SQL_VERSION 17.5.2.2 +ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' +ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 RUN apk update @@ -30,7 +31,9 @@ RUN docker-php-ext-install gd RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql -RUN docker-php-ext-install sockets +RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ + docker-php-ext-install sockets && \ + apk del .phpize-deps RUN docker-php-ext-install bcmath # Install APCu extension @@ -44,13 +47,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_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ +RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql${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_${MS_ODBC_SQL_VERSION}-1_amd64.apk + rm msodbcsql${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/redis/redis-acl.conf b/data/infra/redis/redis-acl.conf new file mode 100644 index 00000000..44c26d66 --- /dev/null +++ b/data/infra/redis/redis-acl.conf @@ -0,0 +1,2 @@ +user foo allcommands allkeys on >bar +requirepass barbar diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 8520b92d..383099e4 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -1,9 +1,10 @@ -FROM php:8.1.9-alpine3.16 +FROM php:8.2-alpine3.17 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV PDO_SQLSRV_VERSION 5.10.1 -ENV MS_ODBC_SQL_VERSION 17.5.2.2 +ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' +ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 RUN apk update @@ -30,7 +31,9 @@ RUN docker-php-ext-install gd RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql -RUN docker-php-ext-install sockets +RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ + docker-php-ext-install sockets && \ + apk del .phpize-deps RUN docker-php-ext-install bcmath # Install APCu extension @@ -44,13 +47,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_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ +RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql${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_${MS_ODBC_SQL_VERSION}-1_amd64.apk + rm msodbcsql${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 21a2fe5e..21e7d95f 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,11 +1,12 @@ -FROM php:8.1.9-alpine3.16 +FROM php:8.2-alpine3.17 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.11.1 +ENV OPENSWOOLE_VERSION 4.12.0 ENV PDO_SQLSRV_VERSION 5.10.1 -ENV MS_ODBC_SQL_VERSION 17.5.2.2 +ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' +ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 RUN apk update @@ -32,7 +33,9 @@ RUN docker-php-ext-install gd RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql -RUN docker-php-ext-install sockets +RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ + docker-php-ext-install sockets && \ + apk del .phpize-deps RUN docker-php-ext-install bcmath # Install APCu extension @@ -54,13 +57,13 @@ RUN mkdir -p /usr/src/php/ext/inotify \ && rm /tmp/inotify.tar.gz # Install openswoole, pcov and mssql driver -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 && \ +RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ + apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ docker-php-ext-enable openswoole pdo_sqlsrv pcov && \ apk del .phpize-deps && \ - rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk + rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk # Install composer COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer diff --git a/docker-compose.yml b/docker-compose.yml index 8293ab03..f3affecb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_redis_acl - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq @@ -65,6 +66,7 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_redis_acl - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq @@ -89,6 +91,7 @@ services: - shlink_db_maria - shlink_db_ms - shlink_redis + - shlink_redis_acl - shlink_mercure - shlink_mercure_proxy - shlink_rabbitmq @@ -146,10 +149,19 @@ services: shlink_redis: container_name: shlink_redis - image: redis:6.0-alpine + image: redis:6.2-alpine ports: - "6380:6379" + shlink_redis_acl: + container_name: shlink_redis_acl + image: redis:6.2-alpine + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + ports: + - "6382:6379" + volumes: + - ./data/infra/redis/redis-acl.conf:/usr/local/etc/redis/redis.conf + shlink_mercure_proxy: container_name: shlink_mercure_proxy image: nginx:1.19.6-alpine diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index f09e8d7b..ab66f506 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -6,6 +6,7 @@ "longUrl", "dateCreated", "visitsCount", + "visitsSummary", "tags", "meta", "domain", @@ -32,8 +33,12 @@ "description": "The date in which the short URL was created in ISO format." }, "visitsCount": { + "deprecated": true, "type": "integer", - "description": "The number of visits that this short URL has received." + "description": "**[DEPRECATED]** Use `visitsSummary.total` instead." + }, + "visitsSummary": { + "$ref": "./ShortUrlVisitsSummary.json" }, "tags": { "type": "array", diff --git a/docs/swagger/definitions/ShortUrlVisitsSummary.json b/docs/swagger/definitions/ShortUrlVisitsSummary.json new file mode 100644 index 00000000..404b7a75 --- /dev/null +++ b/docs/swagger/definitions/ShortUrlVisitsSummary.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": ["total", "nonBots", "bots"], + "properties": { + "total": { + "description": "The total amount of visits that this short URL has received.", + "type": "integer" + }, + "nonBots": { + "description": "The amount of visits which were not identified as bots.", + "type": "integer" + }, + "bots": { + "description": "The amount of visits that were identified as potential bots.", + "type": "integer" + } + } +} diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 2675ab61..8960234a 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -73,10 +73,12 @@ "shortCode-DESC", "dateCreated-ASC", "dateCreated-DESC", + "title-ASC", + "title-DESC", "visits-ASC", "visits-DESC", - "title-ASC", - "title-DESC" + "nonBotVisits-ASC", + "nonBotVisits-DESC" ] } }, @@ -97,6 +99,32 @@ "schema": { "type": "string" } + }, + { + "name": "excludeMaxVisitsReached", + "in": "query", + "description": "If true, short URLs which already reached their maximum amount of visits will be excluded.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + }, + { + "name": "excludePastValidUntil", + "in": "query", + "description": "If true, short URLs which validUntil date is on the past will be excluded.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } } ], "security": [ @@ -136,7 +164,11 @@ "shortUrl": "https://doma.in/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 328, + "visitsSummary": { + "total": 328, + "nonBots": 328, + "bots": 0 + }, "tags": [ "games", "tech" @@ -155,7 +187,11 @@ "shortUrl": "https://doma.in/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, + "visitsSummary": { + "total": 1029, + "nonBots": 900, + "bots": 129 + }, "tags": [ "shlink" ], @@ -173,7 +209,11 @@ "shortUrl": "https://example.com/123bA", "longUrl": "https://www.google.com", "dateCreated": "2015-10-01T20:34:16+02:00", - "visitsCount": 25, + "visitsSummary": { + "total": 25, + "nonBots": 0, + "bots": 25 + }, "tags": [], "meta": { "validSince": "2017-01-21T00:00:00+02:00", @@ -281,7 +321,11 @@ "shortUrl": "https://doma.in/12C18", "longUrl": "https://store.steampowered.com", "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 0, + "visitsSummary": { + "total": 0, + "nonBots": 0, + "bots": 0 + }, "tags": [ "games", "tech" diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index aa26fa1b..254a88f2 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -55,7 +55,11 @@ "shortUrl": "https://doma.in/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 0, + "visitsSummary": { + "total": 0, + "nonBots": 0, + "bots": 0 + }, "tags": [ "games", "tech" diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 1b001cc9..00577f4f 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -41,7 +41,11 @@ "shortUrl": "https://doma.in/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, + "visitsSummary": { + "total": 1029, + "nonBots": 820, + "bots": 209 + }, "tags": [ "shlink" ], @@ -159,7 +163,11 @@ "shortUrl": "https://doma.in/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", - "visitsCount": 1029, + "visitsSummary": { + "total": 1029, + "nonBots": 900, + "bots": 129 + }, "tags": [ "shlink" ], diff --git a/indocker b/indocker index 03061e2f..789386ac 100755 --- a/indocker +++ b/indocker @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Run docker containers if they are not up yet -if ! [[ $(docker ps | grep shlink) ]]; then +if ! [[ $(docker ps | grep shlink_swoole) ]]; then docker-compose up -d fi diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 51a3f2d7..177e4c8b 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -84,7 +84,7 @@ return [ ], Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ - ShortUrl\ShortUrlService::class, + ShortUrl\ShortUrlListService::class, ShortUrl\Transformer\ShortUrlDataTransformer::class, ], Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 588a2fa2..c1ae8f05 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\ApiKey; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Symfony\Component\Console\Input\InputInterface; use function is_string; @@ -19,8 +20,8 @@ class RoleResolver implements RoleResolverInterface public function determineRoles(InputInterface $input): array { - $domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM); - $author = $input->getOption(self::AUTHOR_ONLY_PARAM); + $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); + $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); $roleDefinitions = []; if ($author) { diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php index 98d50483..92a04594 100644 --- a/module/CLI/src/ApiKey/RoleResolverInterface.php +++ b/module/CLI/src/ApiKey/RoleResolverInterface.php @@ -9,9 +9,6 @@ use Symfony\Component\Console\Input\InputInterface; interface RoleResolverInterface { - public const AUTHOR_ONLY_PARAM = 'author-only'; - public const DOMAIN_ONLY_PARAM = 'domain-only'; - /** * @return RoleDefinition[] */ diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index b24619ef..12adcd57 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -32,8 +32,8 @@ class GenerateKeyCommand extends Command protected function configure(): void { - $authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM; - $domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM; + $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); + $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); $help = <<%command.name% generates a new valid API key. diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 0e98af31..59f5b534 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -62,8 +62,8 @@ class ListKeysCommand extends Command $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( fn (Role $role, array $meta) => empty($meta) - ? Role::toFriendlyName($role) - : sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)), + ? $role->toFriendlyName() + : sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)), )); return $rowData; diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 676a2141..8d2eb8c9 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -25,7 +25,7 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 7f81e4da..a6a4f31d 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -20,7 +20,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'short-url:visits'; - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 0889bb03..7a9c77af 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,7 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; +use Shlinkio\Shlink\CLI\Option\EndDateOption; +use Shlinkio\Shlink\CLI\Option\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; @@ -14,7 +15,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -27,20 +29,25 @@ use function Functional\map; use function implode; use function sprintf; -class ListShortUrlsCommand extends AbstractWithDateRangeCommand +class ListShortUrlsCommand extends Command { use PagerfantaUtilsTrait; public const NAME = 'short-url:list'; + private readonly StartDateOption $startDateOption; + private readonly EndDateOption $endDateOption; + public function __construct( - private ShortUrlServiceInterface $shortUrlService, - private DataTransformerInterface $transformer, + private readonly ShortUrlListServiceInterface $shortUrlService, + private readonly DataTransformerInterface $transformer, ) { parent::__construct(); + $this->startDateOption = new StartDateOption($this, 'short URLs'); + $this->endDateOption = new EndDateOption($this, 'short URLs'); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) @@ -70,6 +77,18 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand InputOption::VALUE_NONE, 'If tags is provided, returns only short URLs having ALL tags.', ) + ->addOption( + 'exclude-max-visits-reached', + null, + InputOption::VALUE_NONE, + 'Excludes short URLs which reached their max amount of visits.', + ) + ->addOption( + 'exclude-past-valid-until', + null, + InputOption::VALUE_NONE, + 'Excludes short URLs which have a "validUntil" date in the past.', + ) ->addOption( 'order-by', 'o', @@ -104,16 +123,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ); } - protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName); - } - - protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName); - } - protected function execute(InputInterface $input, OutputInterface $output): ?int { $io = new SymfonyStyle($input, $output); @@ -124,8 +133,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $tags = ! empty($tags) ? explode(',', $tags) : []; $all = $input->getOption('all'); - $startDate = $this->getStartDateOption($input, $output); - $endDate = $this->getEndDateOption($input, $output); + $startDate = $this->startDateOption->get($input, $output); + $endDate = $this->endDateOption->get($input, $output); $orderBy = $this->processOrderBy($input); $columnsMap = $this->resolveColumnsMap($input); @@ -136,6 +145,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ShortUrlsParamsInputFilter::ORDER_BY => $orderBy, ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), + ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'), + ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'), ]; if ($all) { diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 842c9b45..290a172a 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -25,7 +25,7 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php deleted file mode 100644 index c3e3c407..00000000 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -doConfigure(); - $this - ->addOption(self::START_DATE, 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc(self::START_DATE)) - ->addOption(self::END_DATE, 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc(self::END_DATE)); - } - - protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos - { - return $this->getDateOption($input, $output, self::START_DATE); - } - - protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos - { - return $this->getDateOption($input, $output, self::END_DATE); - } - - private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos - { - $value = $input->getOption($key); - if (empty($value) || ! is_string($value)) { - return null; - } - - try { - return Chronos::parse($value); - } catch (Throwable $e) { - $output->writeln(sprintf( - '> Ignored provided "%s" since its value "%s" is not a valid date. <', - $key, - $value, - )); - - if ($output->isVeryVerbose()) { - $this->getApplication()?->renderThrowable($e, $output); - } - - return null; - } - } - - abstract protected function doConfigure(): void; - - abstract protected function getStartDateDesc(string $optionName): string; - - abstract protected function getEndDateDesc(string $optionName): string; -} diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 37a875c6..402d5ba4 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -4,13 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; +use Shlinkio\Shlink\CLI\Option\EndDateOption; +use Shlinkio\Shlink\CLI\Option\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,29 +21,23 @@ use function Functional\map; use function Functional\select_keys; use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; -use function sprintf; -abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand +abstract class AbstractVisitsListCommand extends Command { + private readonly StartDateOption $startDateOption; + private readonly EndDateOption $endDateOption; + public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper) { parent::__construct(); - } - - final protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName); - } - - final protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName); + $this->startDateOption = new StartDateOption($this, 'visits'); + $this->endDateOption = new EndDateOption($this, 'visits'); } final protected function execute(InputInterface $input, OutputInterface $output): ?int { - $startDate = $this->getStartDateOption($input, $output); - $endDate = $this->getEndDateOption($input, $output); + $startDate = $this->startDateOption->get($input, $output); + $endDate = $this->endDateOption->get($input, $output); $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); [$rows, $headers] = $this->resolveRowsAndHeaders($paginator); diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 0b4a4612..0dd32f3e 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -23,7 +23,7 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index c2d353af..618a35cd 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -14,7 +14,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'visit:orphan'; - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Option/DateOption.php b/module/CLI/src/Option/DateOption.php new file mode 100644 index 00000000..a863696f --- /dev/null +++ b/module/CLI/src/Option/DateOption.php @@ -0,0 +1,51 @@ +addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + $value = $input->getOption($this->name); + if (empty($value) || ! is_string($value)) { + return null; + } + + try { + return Chronos::parse($value); + } catch (Throwable $e) { + $output->writeln(sprintf( + '> Ignored provided "%s" since its value "%s" is not a valid date. <', + $this->name, + $value, + )); + + if ($output->isVeryVerbose()) { + $this->command->getApplication()?->renderThrowable($e, $output); + } + + return null; + } + } +} diff --git a/module/CLI/src/Option/EndDateOption.php b/module/CLI/src/Option/EndDateOption.php new file mode 100644 index 00000000..72421981 --- /dev/null +++ b/module/CLI/src/Option/EndDateOption.php @@ -0,0 +1,30 @@ +dateOption = new DateOption($command, 'end-date', 'e', sprintf( + 'Allows to filter %s, returning only those newer than provided date.', + $descriptionHint, + )); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + return $this->dateOption->get($input, $output); + } +} diff --git a/module/CLI/src/Option/StartDateOption.php b/module/CLI/src/Option/StartDateOption.php new file mode 100644 index 00000000..2da5aaee --- /dev/null +++ b/module/CLI/src/Option/StartDateOption.php @@ -0,0 +1,30 @@ +dateOption = new DateOption($command, 'start-date', 's', sprintf( + 'Allows to filter %s, returning only those older than provided date.', + $descriptionHint, + )); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + return $this->dateOption->get($input, $output); + } +} diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php new file mode 100644 index 00000000..c98573a5 --- /dev/null +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -0,0 +1,76 @@ +exec([ListShortUrlsCommand::NAME, ...$flags], ['no']); + self::assertStringContainsString($expectedOutput, $output); + } + + public function provideFlagsAndOutput(): iterable + { + // phpcs:disable Generic.Files.LineLength + yield 'no flags' => [[], << [['--start-date=2019-01'], << [['-e 2018-12-01'], << [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], << [['--exclude-max-visits-reached', '--exclude-past-valid-until'], <<domainService = $this->prophesize(DomainServiceInterface::class); - $this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com'); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->resolver = new RoleResolver($this->domainService, 'default.com'); } /** @@ -36,61 +36,63 @@ class RoleResolverTest extends TestCase array $expectedRoles, int $expectedDomainCalls, ): void { - $getDomain = $this->domainService->getOrCreate('example.com')->willReturn( - Domain::withAuthority('example.com')->setId('1'), - ); + $this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with( + 'example.com', + )->willReturn($this->domainWithId(Domain::withAuthority('example.com'))); $result = $this->resolver->determineRoles($input); self::assertEquals($expectedRoles, $result); - $getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls); } public function provideRoles(): iterable { - $domain = Domain::withAuthority('example.com')->setId('1'); + $domain = $this->domainWithId(Domain::withAuthority('example.com')); $buildInput = function (array $definition): InputInterface { - $input = $this->prophesize(InputInterface::class); + $input = $this->createStub(InputInterface::class); + $input->method('getOption')->willReturnMap( + map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]), + ); - foreach ($definition as $name => $value) { - $input->getOption($name)->willReturn($value); - } - - return $input->reveal(); + return $input; }; yield 'no roles' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]), [], 0, ]; yield 'domain role only' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]), + $buildInput( + [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false], + ), [RoleDefinition::forDomain($domain)], 1, ]; yield 'false domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]), [], 0, ]; yield 'true domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]), [], 0, ]; yield 'string array domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]), [], 0, ]; yield 'author role only' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]), [RoleDefinition::forAuthoredShortUrls()], 0, ]; yield 'both roles' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]), + $buildInput( + [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true], + ), [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)], 1, ]; @@ -99,12 +101,22 @@ class RoleResolverTest extends TestCase /** @test */ public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void { - $input = $this->prophesize(InputInterface::class); - $input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com'); - $input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null); + $input = $this->createStub(InputInterface::class); + $input + ->method('getOption') + ->willReturnMap([ + [Role::DOMAIN_SPECIFIC->paramName(), 'default.com'], + [Role::AUTHORED_SHORT_URLS->paramName(), null], + ]); $this->expectException(InvalidRoleConfigException::class); - $this->resolver->determineRoles($input->reveal()); + $this->resolver->determineRoles($input); + } + + private function domainWithId(Domain $domain): Domain + { + $domain->setId('1'); + return $domain; } } diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/CliTestUtilsTrait.php index ec7dd9d9..761567ae 100644 --- a/module/CLI/test/CliTestUtilsTrait.php +++ b/module/CLI/test/CliTestUtilsTrait.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputDefinition; @@ -14,21 +13,14 @@ use Symfony\Component\Console\Tester\CommandTester; trait CliTestUtilsTrait { - use ProphecyTrait; - - /** - * @return ObjectProphecy|Command - */ - private function createCommandMock(string $name): ObjectProphecy + private function createCommandMock(string $name): MockObject & Command { - $command = $this->prophesize(Command::class); - $command->getName()->willReturn($name); - $command->getDefinition()->willReturn($name); - $command->isEnabled()->willReturn(true); - $command->getAliases()->willReturn([]); - $command->getDefinition()->willReturn(new InputDefinition()); - $command->setApplication(Argument::type(Application::class))->willReturn(function (): void { - }); + $command = $this->createMock(Command::class); + $command->method('getName')->willReturn($name); + $command->method('isEnabled')->willReturn(true); + $command->method('getAliases')->willReturn([]); + $command->method('getDefinition')->willReturn(new InputDefinition()); + $command->method('setApplication')->with(Assert::isInstanceOf(Application::class)); return $command; } diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 41a4f982..3a3c2def 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -17,19 +17,19 @@ class DisableKeyCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $apiKeyService; + private MockObject & ApiKeyServiceInterface $apiKeyService; protected function setUp(): void { - $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService->reveal())); + $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); + $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService)); } /** @test */ public function providedApiKeyIsDisabled(): void { $apiKey = 'abcd1234'; - $this->apiKeyService->disable($apiKey)->shouldBeCalledOnce(); + $this->apiKeyService->expects($this->once())->method('disable')->with($apiKey); $this->commandTester->execute([ 'apiKey' => $apiKey, @@ -44,7 +44,9 @@ class DisableKeyCommandTest extends TestCase { $apiKey = 'abcd1234'; $expectedMessage = 'API key "abcd1234" does not exist.'; - $disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage)); + $this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException( + new InvalidArgumentException($expectedMessage), + ); $this->commandTester->execute([ 'apiKey' => $apiKey, @@ -52,6 +54,5 @@ class DisableKeyCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString($expectedMessage, $output); - $disable->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 6db8581b..631a01c8 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,22 +20,25 @@ class GenerateKeyCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $apiKeyService; + private MockObject & ApiKeyServiceInterface $apiKeyService; protected function setUp(): void { - $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $roleResolver = $this->prophesize(RoleResolverInterface::class); - $roleResolver->determineRoles(Argument::type(InputInterface::class))->willReturn([]); + $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); + $roleResolver = $this->createMock(RoleResolverInterface::class); + $roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]); - $command = new GenerateKeyCommand($this->apiKeyService->reveal(), $roleResolver->reveal()); + $command = new GenerateKeyCommand($this->apiKeyService, $roleResolver); $this->commandTester = $this->testerForCommand($command); } /** @test */ public function noExpirationDateIsDefinedIfNotProvided(): void { - $this->apiKeyService->create(null, null)->shouldBeCalledOnce()->willReturn(ApiKey::create()); + $this->apiKeyService->expects($this->once())->method('create')->with( + $this->isNull(), + $this->isNull(), + )->willReturn(ApiKey::create()); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -47,9 +49,10 @@ class GenerateKeyCommandTest extends TestCase /** @test */ public function expirationDateIsDefinedIfProvided(): void { - $this->apiKeyService->create(Argument::type(Chronos::class), null)->shouldBeCalledOnce()->willReturn( - ApiKey::create(), - ); + $this->apiKeyService->expects($this->once())->method('create')->with( + $this->isInstanceOf(Chronos::class), + $this->isNull(), + )->willReturn(ApiKey::create()); $this->commandTester->execute([ '--expiration-date' => '2016-01-01', @@ -59,9 +62,10 @@ class GenerateKeyCommandTest extends TestCase /** @test */ public function nameIsDefinedIfProvided(): void { - $this->apiKeyService->create(null, Argument::type('string'))->shouldBeCalledOnce()->willReturn( - ApiKey::create(), - ); + $this->apiKeyService->expects($this->once())->method('create')->with( + $this->isNull(), + $this->isType('string'), + )->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 c52f466f..f4101ec6 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; @@ -21,12 +21,12 @@ class ListKeysCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $apiKeyService; + private MockObject & ApiKeyServiceInterface $apiKeyService; protected function setUp(): void { - $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService->reveal())); + $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); + $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService)); } /** @@ -35,13 +35,12 @@ class ListKeysCommandTest extends TestCase */ public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void { - $listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys); + $this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys); $this->commandTester->execute(['--enabled-only' => $enabledOnly]); $output = $this->commandTester->getDisplay(); self::assertEquals($expected, $output); - $listKeys->shouldHaveBeenCalledOnce(); } public function provideKeysAndOutputs(): iterable @@ -87,12 +86,12 @@ class ListKeysCommandTest extends TestCase $apiKey1 = ApiKey::create(), $apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]), $apiKey3 = $this->apiKeyWithRoles( - [RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1'))], + [RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com')))], ), $apiKey4 = ApiKey::create(), $apiKey5 = $this->apiKeyWithRoles([ RoleDefinition::forAuthoredShortUrls(), - RoleDefinition::forDomain(Domain::withAuthority('example.com')->setId('1')), + RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com'))), ]), $apiKey6 = ApiKey::create(), ], @@ -151,4 +150,10 @@ class ListKeysCommandTest extends TestCase return $apiKey; } + + private function domainWithId(Domain $domain): Domain + { + $domain->setId('1'); + return $domain; + } } diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index f500775a..bf1eac98 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -9,9 +9,8 @@ use Doctrine\DBAL\Driver; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -28,40 +27,37 @@ class CreateDatabaseCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $processHelper; - private ObjectProphecy $regularConn; - private ObjectProphecy $schemaManager; - private ObjectProphecy $driver; + private MockObject & ProcessRunnerInterface $processHelper; + private MockObject & Connection $regularConn; + private MockObject & AbstractSchemaManager $schemaManager; + private MockObject & Driver $driver; protected function setUp(): void { - $locker = $this->prophesize(LockFactory::class); - $lock = $this->prophesize(LockInterface::class); - $lock->acquire(Argument::any())->willReturn(true); - $lock->release()->will(function (): void { - }); - $locker->createLock(Argument::cetera())->willReturn($lock->reveal()); + $locker = $this->createMock(LockFactory::class); + $lock = $this->createMock(LockInterface::class); + $lock->method('acquire')->withAnyParameters()->willReturn(true); + $locker->method('createLock')->withAnyParameters()->willReturn($lock); - $phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class); - $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php'); + $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class); + $phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); - $this->schemaManager = $this->prophesize(AbstractSchemaManager::class); + $this->processHelper = $this->createMock(ProcessRunnerInterface::class); + $this->schemaManager = $this->createMock(AbstractSchemaManager::class); - $this->regularConn = $this->prophesize(Connection::class); - $this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); - $this->driver = $this->prophesize(Driver::class); - $this->regularConn->getDriver()->willReturn($this->driver->reveal()); - $this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal()); - $noDbNameConn = $this->prophesize(Connection::class); - $noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); + $this->regularConn = $this->createMock(Connection::class); + $this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager); + $this->driver = $this->createMock(Driver::class); + $this->regularConn->method('getDriver')->willReturn($this->driver); + $noDbNameConn = $this->createMock(Connection::class); + $noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager); $command = new CreateDatabaseCommand( - $locker->reveal(), - $this->processHelper->reveal(), - $phpExecutableFinder->reveal(), - $this->regularConn->reveal(), - $noDbNameConn->reveal(), + $locker, + $this->processHelper, + $phpExecutableFinder, + $this->regularConn, + $noDbNameConn, ); $this->commandTester = $this->testerForCommand($command); @@ -71,38 +67,33 @@ class CreateDatabaseCommandTest extends TestCase public function successMessageIsPrintedIfDatabaseAlreadyExists(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); - $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); - $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { - }); - $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']); + $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); + $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( + ['foo', $shlinkDatabase, 'bar'], + ); + $this->schemaManager->expects($this->never())->method('createDatabase'); + $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output); - $getDatabase->shouldHaveBeenCalledOnce(); - $listDatabases->shouldHaveBeenCalledOnce(); - $createDatabase->shouldNotHaveBeenCalled(); - $listTables->shouldHaveBeenCalledOnce(); } /** @test */ public function databaseIsCreatedIfItDoesNotExist(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); - $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); - $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { - }); - $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]); + $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); + $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']); + $this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase); + $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn( + ['foo_table', 'bar_table', MIGRATIONS_TABLE], + ); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->commandTester->execute([]); - - $getDatabase->shouldHaveBeenCalledOnce(); - $listDatabases->shouldHaveBeenCalledOnce(); - $createDatabase->shouldHaveBeenCalledOnce(); - $listTables->shouldHaveBeenCalledOnce(); } /** @@ -112,28 +103,25 @@ class CreateDatabaseCommandTest extends TestCase public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); - $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); - $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { - }); - $listTables = $this->schemaManager->listTableNames()->willReturn($tables); - $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ + $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); + $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( + ['foo', $shlinkDatabase, 'bar'], + ); + $this->schemaManager->expects($this->never())->method('createDatabase'); + $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn($tables); + $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ '/usr/local/bin/php', CreateDatabaseCommand::DOCTRINE_SCRIPT, CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND, '--no-interaction', ]); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Creating database tables...', $output); self::assertStringContainsString('Database properly created!', $output); - $getDatabase->shouldHaveBeenCalledOnce(); - $listDatabases->shouldHaveBeenCalledOnce(); - $createDatabase->shouldNotHaveBeenCalled(); - $listTables->shouldHaveBeenCalledOnce(); - $runCommand->shouldHaveBeenCalledOnce(); } public function provideEmptyDatabase(): iterable @@ -145,20 +133,13 @@ class CreateDatabaseCommandTest extends TestCase /** @test */ public function databaseCheckIsSkippedForSqlite(): void { - $this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal()); + $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class)); - $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); - $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); - $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { - }); - $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']); + $this->regularConn->expects($this->never())->method('getParams'); + $this->schemaManager->expects($this->never())->method('listDatabases'); + $this->schemaManager->expects($this->never())->method('createDatabase'); + $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); $this->commandTester->execute([]); - - $getDatabase->shouldNotHaveBeenCalled(); - $listDatabases->shouldNotHaveBeenCalled(); - $createDatabase->shouldNotHaveBeenCalled(); - $listTables->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 1a8dfb0e..7027ca21 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Db; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -21,34 +20,28 @@ class MigrateDatabaseCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $processHelper; + private MockObject & ProcessRunnerInterface $processHelper; protected function setUp(): void { - $locker = $this->prophesize(LockFactory::class); - $lock = $this->prophesize(LockInterface::class); - $lock->acquire(Argument::any())->willReturn(true); - $lock->release()->will(function (): void { - }); - $locker->createLock(Argument::cetera())->willReturn($lock->reveal()); + $locker = $this->createMock(LockFactory::class); + $lock = $this->createMock(LockInterface::class); + $lock->method('acquire')->withAnyParameters()->willReturn(true); + $locker->method('createLock')->withAnyParameters()->willReturn($lock); - $phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class); - $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php'); + $phpExecutableFinder = $this->createMock(PhpExecutableFinder::class); + $phpExecutableFinder->method('find')->with($this->isFalse())->willReturn('/usr/local/bin/php'); - $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); + $this->processHelper = $this->createMock(ProcessRunnerInterface::class); - $command = new MigrateDatabaseCommand( - $locker->reveal(), - $this->processHelper->reveal(), - $phpExecutableFinder->reveal(), - ); + $command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder); $this->commandTester = $this->testerForCommand($command); } /** @test */ public function migrationsCommandIsRunWithProperVerbosity(): void { - $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ + $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ '/usr/local/bin/php', MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT, MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND, @@ -60,6 +53,5 @@ class MigrateDatabaseCommandTest extends TestCase self::assertStringContainsString('Migrating database...', $output); self::assertStringContainsString('Database properly migrated!', $output); - $runCommand->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 0f8c76af..1bf5cec3 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Domain; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; @@ -22,12 +22,12 @@ class DomainRedirectsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $domainService; + private MockObject & DomainServiceInterface $domainService; protected function setUp(): void { - $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService->reveal())); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService)); } /** @@ -37,11 +37,14 @@ class DomainRedirectsCommandTest extends TestCase public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void { $domainAuthority = 'my-domain.com'; - $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); - $configureRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( + $domain, + ); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $domainAuthority, NotFoundRedirects::withRedirects('foo.com', null, 'baz.com'), )->willReturn(Domain::withAuthority('')); + $this->domainService->expects($this->never())->method('listDomains'); $this->commandTester->setInputs(['foo.com', '', 'baz.com']); $this->commandTester->execute(['domain' => $domainAuthority]); @@ -55,9 +58,6 @@ class DomainRedirectsCommandTest extends TestCase ); self::assertStringContainsString('URL to redirect to when a user hits an invalid short URL', $output); self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)')); - $findDomain->shouldHaveBeenCalledOnce(); - $configureRedirects->shouldHaveBeenCalledOnce(); - $this->domainService->listDomains()->shouldNotHaveBeenCalled(); } public function provideDomains(): iterable @@ -73,11 +73,14 @@ class DomainRedirectsCommandTest extends TestCase $domain = Domain::withAuthority($domainAuthority); $domain->configureNotFoundRedirects(NotFoundRedirects::withRedirects('foo.com', 'bar.com', 'baz.com')); - $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); - $configureRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( + $domain, + ); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $domainAuthority, NotFoundRedirects::withRedirects(null, 'edited.com', 'baz.com'), )->willReturn($domain); + $this->domainService->expects($this->never())->method('listDomains'); $this->commandTester->setInputs(['2', '1', 'edited.com', '0']); $this->commandTester->execute(['domain' => $domainAuthority]); @@ -90,9 +93,6 @@ class DomainRedirectsCommandTest extends TestCase self::assertStringNotContainsStringIgnoringCase('(Leave empty for no redirect)', $output); self::assertEquals(3, substr_count($output, 'Set new redirect URL')); self::assertEquals(3, substr_count($output, 'Remove redirect')); - $findDomain->shouldHaveBeenCalledOnce(); - $configureRedirects->shouldHaveBeenCalledOnce(); - $this->domainService->listDomains()->shouldNotHaveBeenCalled(); } /** @test */ @@ -101,9 +101,11 @@ class DomainRedirectsCommandTest extends TestCase $domainAuthority = 'example.com'; $domain = Domain::withAuthority($domainAuthority); - $listDomains = $this->domainService->listDomains()->willReturn([]); - $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); - $configureRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([]); + $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( + $domain, + ); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $domainAuthority, NotFoundRedirects::withoutRedirects(), )->willReturn($domain); @@ -113,9 +115,6 @@ class DomainRedirectsCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output); - $listDomains->shouldHaveBeenCalledOnce(); - $findDomain->shouldHaveBeenCalledOnce(); - $configureRedirects->shouldHaveBeenCalledOnce(); } /** @test */ @@ -124,13 +123,15 @@ class DomainRedirectsCommandTest extends TestCase $domainAuthority = 'existing-two.com'; $domain = Domain::withAuthority($domainAuthority); - $listDomains = $this->domainService->listDomains()->willReturn([ + $this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([ DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')), DomainItem::forNonDefaultDomain(Domain::withAuthority($domainAuthority)), ]); - $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); - $configureRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( + $domain, + ); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $domainAuthority, NotFoundRedirects::withoutRedirects(), )->willReturn($domain); @@ -143,9 +144,6 @@ class DomainRedirectsCommandTest extends TestCase self::assertStringNotContainsString('default-domain.com', $output); self::assertStringContainsString('existing-one.com', $output); self::assertStringContainsString($domainAuthority, $output); - $listDomains->shouldHaveBeenCalledOnce(); - $findDomain->shouldHaveBeenCalledOnce(); - $configureRedirects->shouldHaveBeenCalledOnce(); } /** @test */ @@ -154,13 +152,15 @@ class DomainRedirectsCommandTest extends TestCase $domainAuthority = 'new-domain.com'; $domain = Domain::withAuthority($domainAuthority); - $listDomains = $this->domainService->listDomains()->willReturn([ + $this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([ DomainItem::forDefaultDomain('default-domain.com', new NotFoundRedirectOptions()), DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-one.com')), DomainItem::forNonDefaultDomain(Domain::withAuthority('existing-two.com')), ]); - $findDomain = $this->domainService->findByAuthority($domainAuthority)->willReturn($domain); - $configureRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( + $domain, + ); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $domainAuthority, NotFoundRedirects::withoutRedirects(), )->willReturn($domain); @@ -173,8 +173,5 @@ class DomainRedirectsCommandTest extends TestCase self::assertStringNotContainsString('default-domain.com', $output); self::assertStringContainsString('existing-one.com', $output); self::assertStringContainsString('existing-two.com', $output); - $listDomains->shouldHaveBeenCalledOnce(); - $findDomain->shouldHaveBeenCalledOnce(); - $configureRedirects->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index 85c5ef0c..e02aa36a 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Domain; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -25,16 +24,16 @@ class GetDomainVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsHelper; - private ObjectProphecy $stringifier; + private MockObject & VisitsStatsHelperInterface $visitsHelper; + private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); $this->commandTester = $this->testerForCommand( - new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier), ); } @@ -46,10 +45,13 @@ class GetDomainVisitsCommandTest extends TestCase VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $domain = 'doma.in'; - $getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn( - new Paginator(new ArrayAdapter([$visit])), + $this->visitsHelper->expects($this->once())->method('visitsForDomain')->with( + $domain, + $this->anything(), + )->willReturn(new Paginator(new ArrayAdapter([$visit]))); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( + 'the_short_url', ); - $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); $this->commandTester->execute(['domain' => $domain]); $output = $this->commandTester->getDisplay(); @@ -65,7 +67,5 @@ class GetDomainVisitsCommandTest extends TestCase OUTPUT, $output, ); - $getVisits->shouldHaveBeenCalledOnce(); - $stringify->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index efaa25ed..0275ba87 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Domain; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; @@ -21,12 +21,12 @@ class ListDomainsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $domainService; + private MockObject & DomainServiceInterface $domainService; protected function setUp(): void { - $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService->reveal())); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService)); } /** @@ -42,7 +42,7 @@ class ListDomainsCommandTest extends TestCase 'https://foo.com/baz-domain/invalid', )); - $listDomains = $this->domainService->listDomains()->willReturn([ + $this->domainService->expects($this->once())->method('listDomains')->with()->willReturn([ DomainItem::forDefaultDomain('foo.com', new NotFoundRedirectOptions( invalidShortUrl: 'https://foo.com/default/invalid', baseUrl: 'https://foo.com/default/base', @@ -55,7 +55,6 @@ class ListDomainsCommandTest extends TestCase self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - $listDomains->shouldHaveBeenCalledOnce(); } public function provideInputsAndOutputs(): iterable diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 985a50a4..734089c9 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; @@ -16,7 +15,7 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\UrlShortener; +use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; @@ -27,19 +26,21 @@ class CreateShortUrlCommandTest extends TestCase private const DEFAULT_DOMAIN = 'default.com'; private CommandTester $commandTester; - private ObjectProphecy $urlShortener; - private ObjectProphecy $stringifier; + private MockObject & UrlShortenerInterface $urlShortener; + private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { - $this->urlShortener = $this->prophesize(UrlShortener::class); - $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); - $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn(''); + $this->urlShortener = $this->createMock(UrlShortenerInterface::class); + $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); $command = new CreateShortUrlCommand( - $this->urlShortener->reveal(), - $this->stringifier->reveal(), - new UrlShortenerOptions(domain: ['hostname' => self::DEFAULT_DOMAIN], defaultShortCodesLength: 5), + $this->urlShortener, + $this->stringifier, + new UrlShortenerOptions( + domain: ['hostname' => self::DEFAULT_DOMAIN, 'schema' => ''], + defaultShortCodesLength: 5, + ), ); $this->commandTester = $this->testerForCommand($command); } @@ -48,8 +49,10 @@ class CreateShortUrlCommandTest extends TestCase public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void { $shortUrl = ShortUrl::createEmpty(); - $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); - $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url'); + $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( + 'stringified_short_url', + ); $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', @@ -59,16 +62,16 @@ class CreateShortUrlCommandTest extends TestCase self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertStringContainsString('stringified_short_url', $output); - $urlToShortCode->shouldHaveBeenCalledOnce(); - $stringify->shouldHaveBeenCalledOnce(); } /** @test */ public function exceptionWhileParsingLongUrlOutputsError(): void { $url = 'http://domain.com/invalid'; - $this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url)) - ->shouldBeCalledOnce(); + $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException( + InvalidUrlException::fromUrl($url), + ); + $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $this->commandTester->execute(['longUrl' => $url]); $output = $this->commandTester->getDisplay(); @@ -80,30 +83,32 @@ class CreateShortUrlCommandTest extends TestCase /** @test */ public function providingNonUniqueSlugOutputsError(): void { - $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow( + $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException( NonUniqueSlugException::fromSlug('my-slug'), ); + $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); $output = $this->commandTester->getDisplay(); self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); self::assertStringContainsString('Provided slug "my-slug" is already in use', $output); - $urlToShortCode->shouldHaveBeenCalledOnce(); } /** @test */ public function properlyProcessesProvidedTags(): void { $shortUrl = ShortUrl::createEmpty(); - $urlToShortCode = $this->urlShortener->shorten( - Argument::that(function (ShortUrlCreation $meta) { + $this->urlShortener->expects($this->once())->method('shorten')->with( + $this->callback(function (ShortUrlCreation $meta) { $tags = $meta->getTags(); Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); return true; }), )->willReturn($shortUrl); - $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url'); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( + 'stringified_short_url', + ); $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', @@ -113,8 +118,6 @@ class CreateShortUrlCommandTest extends TestCase self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertStringContainsString('stringified_short_url', $output); - $urlToShortCode->shouldHaveBeenCalledOnce(); - $stringify->shouldHaveBeenCalledOnce(); } /** @@ -123,18 +126,18 @@ class CreateShortUrlCommandTest extends TestCase */ public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void { - $shorten = $this->urlShortener->shorten( - Argument::that(function (ShortUrlCreation $meta) use ($expectedDomain) { + $this->urlShortener->expects($this->once())->method('shorten')->with( + $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { Assert::assertEquals($expectedDomain, $meta->getDomain()); return true; }), )->willReturn(ShortUrl::createEmpty()); + $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $input['longUrl'] = 'http://domain.com/foo/bar'; $this->commandTester->execute($input); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - $shorten->shouldHaveBeenCalledOnce(); } public function provideDomains(): iterable @@ -152,17 +155,16 @@ class CreateShortUrlCommandTest extends TestCase public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void { $shortUrl = ShortUrl::createEmpty(); - $urlToShortCode = $this->urlShortener->shorten( - Argument::that(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { + $this->urlShortener->expects($this->once())->method('shorten')->with( + $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); - return $meta; + return true; }), )->willReturn($shortUrl); + $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $options['longUrl'] = 'http://domain.com/foo/bar'; $this->commandTester->execute($options); - - $urlToShortCode->shouldHaveBeenCalledOnce(); } public function provideFlags(): iterable diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 92aca306..09d48d12 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; @@ -14,7 +13,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; -use function array_pop; use function sprintf; use const PHP_EOL; @@ -24,23 +22,22 @@ class DeleteShortUrlCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $service; + private MockObject & DeleteShortUrlServiceInterface $service; protected function setUp(): void { - $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service->reveal())); + $this->service = $this->createMock(DeleteShortUrlServiceInterface::class); + $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service)); } /** @test */ public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode( + $this->service->expects($this->once())->method('deleteByShortCode')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - false, - )->will(function (): void { - }); + $this->isFalse(), + ); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -49,7 +46,6 @@ class DeleteShortUrlCommandTest extends TestCase sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output, ); - $deleteByShortCode->shouldHaveBeenCalledOnce(); } /** @test */ @@ -57,15 +53,15 @@ class DeleteShortUrlCommandTest extends TestCase { $shortCode = 'abc123'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow( - Exception\ShortUrlNotFoundException::fromNotFound($identifier), - ); + $this->service->expects($this->once())->method('deleteByShortCode')->with( + $identifier, + $this->isFalse(), + )->willThrowException(Exception\ShortUrlNotFoundException::fromNotFound($identifier)); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); - $deleteByShortCode->shouldHaveBeenCalledOnce(); } /** @@ -79,18 +75,17 @@ class DeleteShortUrlCommandTest extends TestCase ): void { $shortCode = 'abc123'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will( - function (array $args) use ($shortCode): void { - $ignoreThreshold = array_pop($args); - - if (!$ignoreThreshold) { - throw Exception\DeleteShortUrlException::fromVisitsThreshold( - 10, - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - ); - } - }, - ); + $this->service->expects($this->exactly($expectedDeleteCalls))->method('deleteByShortCode')->with( + $identifier, + $this->isType('bool'), + )->willReturnCallback(function ($_, bool $ignoreThreshold) use ($shortCode): void { + if (!$ignoreThreshold) { + throw Exception\DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ); + } + }); $this->commandTester->setInputs($retryAnswer); $this->commandTester->execute(['shortCode' => $shortCode]); @@ -101,7 +96,6 @@ class DeleteShortUrlCommandTest extends TestCase $shortCode, ), $output); self::assertStringContainsString($expectedMessage, $output); - $deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls); } public function provideRetryDeleteAnswers(): iterable @@ -115,10 +109,10 @@ class DeleteShortUrlCommandTest extends TestCase public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode( + $this->service->expects($this->once())->method('deleteByShortCode')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - false, - )->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold( + $this->isFalse(), + )->willThrowException(Exception\DeleteShortUrlException::fromVisitsThreshold( 10, ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), )); @@ -132,6 +126,5 @@ class DeleteShortUrlCommandTest extends TestCase $shortCode, ), $output); self::assertStringContainsString('Short URL was not deleted.', $output); - $deleteByShortCode->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 00fca968..8706699b 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; @@ -31,12 +30,12 @@ class GetShortUrlVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal()); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $command = new GetShortUrlVisitsCommand($this->visitsHelper); $this->commandTester = $this->testerForCommand($command); } @@ -44,12 +43,10 @@ class GetShortUrlVisitsCommandTest extends TestCase public function noDateFlagsTriesToListWithoutDateRange(): void { $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl( + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams(DateRange::allTime()), - ) - ->willReturn(new Paginator(new ArrayAdapter([]))) - ->shouldBeCalledOnce(); + )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute(['shortCode' => $shortCode]); } @@ -60,12 +57,10 @@ class GetShortUrlVisitsCommandTest extends TestCase $shortCode = 'abc123'; $startDate = '2016-01-01'; $endDate = '2016-02-01'; - $this->visitsHelper->visitsForShortUrl( + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))), - ) - ->willReturn(new Paginator(new ArrayAdapter([]))) - ->shouldBeCalledOnce(); + )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute([ 'shortCode' => $shortCode, @@ -79,7 +74,7 @@ class GetShortUrlVisitsCommandTest extends TestCase { $shortCode = 'abc123'; $startDate = 'foo'; - $info = $this->visitsHelper->visitsForShortUrl( + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams(DateRange::allTime()), )->willReturn(new Paginator(new ArrayAdapter([]))); @@ -90,7 +85,6 @@ class GetShortUrlVisitsCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $info->shouldHaveBeenCalledOnce(); self::assertStringContainsString( sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate), $output, @@ -104,12 +98,10 @@ class GetShortUrlVisitsCommandTest extends TestCase VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl( + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - Argument::any(), - )->willReturn( - new Paginator(new ArrayAdapter([$visit])), - )->shouldBeCalledOnce(); + $this->anything(), + )->willReturn(new Paginator(new ArrayAdapter([$visit]))); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 5659059a..9b45869f 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use Cake\Chronos\Chronos; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -16,7 +15,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -31,12 +30,12 @@ class ListShortUrlsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $shortUrlService; + private MockObject & ShortUrlListServiceInterface $shortUrlService; protected function setUp(): void { - $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer( + $this->shortUrlService = $this->createMock(ShortUrlListServiceInterface::class); + $command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); $this->commandTester = $this->testerForCommand($command); @@ -51,9 +50,8 @@ class ListShortUrlsCommandTest extends TestCase $data[] = ShortUrl::withLongUrl('url_' . $i); } - $this->shortUrlService->listShortUrls(Argument::cetera()) - ->will(fn () => new Paginator(new ArrayAdapter($data))) - ->shouldBeCalledTimes(3); + $this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters() + ->willReturnCallback(fn () => new Paginator(new ArrayAdapter($data))); $this->commandTester->setInputs(['y', 'y', 'n']); $this->commandTester->execute([]); @@ -74,9 +72,9 @@ class ListShortUrlsCommandTest extends TestCase $data[] = ShortUrl::withLongUrl('url_' . $i); } - $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) - ->willReturn(new Paginator(new ArrayAdapter($data))) - ->shouldBeCalledOnce(); + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( + ShortUrlsParams::emptyInstance(), + )->willReturn(new Paginator(new ArrayAdapter($data))); $this->commandTester->setInputs(['n']); $this->commandTester->execute([]); @@ -95,9 +93,9 @@ class ListShortUrlsCommandTest extends TestCase public function passingPageWillMakeListStartOnThatPage(): void { $page = 5; - $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page])) - ->willReturn(new Paginator(new ArrayAdapter([]))) - ->shouldBeCalledOnce(); + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( + ShortUrlsParams::fromRawData(['page' => $page]), + )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['y']); $this->commandTester->execute(['--page' => $page]); @@ -113,15 +111,15 @@ class ListShortUrlsCommandTest extends TestCase array $notExpectedContents, ApiKey $apiKey, ): void { - $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance()) - ->willReturn(new Paginator(new ArrayAdapter([ - ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo.com', - 'tags' => ['foo', 'bar', 'baz'], - 'apiKey' => $apiKey, - ])), - ]))) - ->shouldBeCalledOnce(); + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( + ShortUrlsParams::emptyInstance(), + )->willReturn(new Paginator(new ArrayAdapter([ + ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo.com', + 'tags' => ['foo', 'bar', 'baz'], + 'apiKey' => $apiKey, + ])), + ]))); $this->commandTester->setInputs(['y']); $this->commandTester->execute($input); @@ -189,7 +187,7 @@ class ListShortUrlsCommandTest extends TestCase ?string $startDate = null, ?string $endDate = null, ): void { - $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([ + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => $page, 'searchTerm' => $searchTerm, 'tags' => $tags, @@ -200,8 +198,6 @@ class ListShortUrlsCommandTest extends TestCase $this->commandTester->setInputs(['n']); $this->commandTester->execute($commandArgs); - - $listShortUrls->shouldHaveBeenCalledOnce(); } public function provideArgs(): iterable @@ -251,14 +247,12 @@ class ListShortUrlsCommandTest extends TestCase */ public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void { - $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([ + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'orderBy' => $expectedOrderBy, ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->setInputs(['n']); $this->commandTester->execute($commandArgs); - - $listShortUrls->shouldHaveBeenCalledOnce(); } public function provideOrderBy(): iterable @@ -273,7 +267,7 @@ class ListShortUrlsCommandTest extends TestCase /** @test */ public function requestingAllElementsWillSetItemsPerPage(): void { - $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([ + $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => 1, 'searchTerm' => null, 'tags' => [], @@ -285,7 +279,5 @@ class ListShortUrlsCommandTest extends TestCase ]))->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute(['--all' => true]); - - $listShortUrls->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 6050f736..89614e6f 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -23,12 +23,12 @@ class ResolveUrlCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $urlResolver; + private MockObject & ShortUrlResolverInterface $urlResolver; protected function setUp(): void { - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver->reveal())); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver)); } /** @test */ @@ -37,9 +37,9 @@ class ResolveUrlCommandTest extends TestCase $shortCode = 'abc123'; $expectedUrl = 'http://domain.com/foo/bar'; $shortUrl = ShortUrl::withLongUrl($expectedUrl); - $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn( - $shortUrl, - )->shouldBeCalledOnce(); + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + )->willReturn($shortUrl); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -52,9 +52,9 @@ class ResolveUrlCommandTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123'); $shortCode = $identifier->shortCode; - $this->urlResolver->resolveShortUrl($identifier) - ->willThrow(ShortUrlNotFoundException::fromNotFound($identifier)) - ->shouldBeCalledOnce(); + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->with($identifier)->willThrowException( + ShortUrlNotFoundException::fromNotFound($identifier), + ); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index b03bf1ee..0528af24 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -16,12 +16,12 @@ class DeleteTagsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService->reveal())); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService)); } /** @test */ @@ -37,8 +37,7 @@ class DeleteTagsCommandTest extends TestCase public function serviceIsInvokedOnSuccess(): void { $tagNames = ['foo', 'bar']; - $deleteTags = $this->tagService->deleteTags($tagNames)->will(function (): void { - }); + $this->tagService->expects($this->once())->method('deleteTags')->with($tagNames); $this->commandTester->execute([ '--name' => $tagNames, @@ -46,6 +45,5 @@ class DeleteTagsCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Tags properly deleted', $output); - $deleteTags->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index dedbbf83..be56cdee 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -25,16 +24,16 @@ class GetTagVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsHelper; - private ObjectProphecy $stringifier; + private MockObject & VisitsStatsHelperInterface $visitsHelper; + private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); $this->commandTester = $this->testerForCommand( - new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + new GetTagVisitsCommand($this->visitsHelper, $this->stringifier), ); } @@ -46,10 +45,10 @@ class GetTagVisitsCommandTest extends TestCase VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $tag = 'abc123'; - $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn( + $this->visitsHelper->expects($this->once())->method('visitsForTag')->with($tag, $this->anything())->willReturn( new Paginator(new ArrayAdapter([$visit])), ); - $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url'); $this->commandTester->execute(['tag' => $tag]); $output = $this->commandTester->getDisplay(); @@ -65,7 +64,5 @@ class GetTagVisitsCommandTest extends TestCase OUTPUT, $output, ); - $getVisits->shouldHaveBeenCalledOnce(); - $stringify->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 58ae1ef1..6ac53f8a 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; @@ -20,33 +19,36 @@ class ListTagsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService->reveal())); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService)); } /** @test */ public function noTagsPrintsEmptyMessage(): void { - $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([]))); + $this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn( + new Paginator(new ArrayAdapter([])), + ); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('No tags found', $output); - $tagsInfo->shouldHaveBeenCalled(); } /** @test */ public function listOfTagsIsPrinted(): void { - $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([ - new TagInfo('foo', 10, 2), - new TagInfo('bar', 7, 32), - ]))); + $this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn( + new Paginator(new ArrayAdapter([ + new TagInfo('foo', 10, 2), + new TagInfo('bar', 7, 32), + ])), + ); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -63,6 +65,5 @@ class ListTagsCommandTest extends TestCase OUTPUT, $output, ); - $tagsInfo->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 9de8d154..95a1e85d 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Tag\Entity\Tag; @@ -19,12 +19,12 @@ class RenameTagCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService->reveal())); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService)); } /** @test */ @@ -32,9 +32,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willThrow( - TagNotFoundException::fromTag('foo'), - ); + $this->tagService->expects($this->once())->method('renameTag')->with( + TagRenaming::fromNames($oldName, $newName), + )->willThrowException(TagNotFoundException::fromTag('foo')); $this->commandTester->execute([ 'oldName' => $oldName, @@ -43,7 +43,6 @@ class RenameTagCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Tag with name "foo" could not be found', $output); - $renameTag->shouldHaveBeenCalled(); } /** @test */ @@ -51,9 +50,9 @@ class RenameTagCommandTest extends TestCase { $oldName = 'foo'; $newName = 'bar'; - $renameTag = $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName))->willReturn( - new Tag($newName), - ); + $this->tagService->expects($this->once())->method('renameTag')->with( + TagRenaming::fromNames($oldName, $newName), + )->willReturn(new Tag($newName)); $this->commandTester->execute([ 'oldName' => $oldName, @@ -62,6 +61,5 @@ class RenameTagCommandTest extends TestCase $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Tag properly renamed', $output); - $renameTag->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 93405799..742fa31b 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Visit; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface; @@ -22,12 +21,12 @@ class DownloadGeoLiteDbCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $dbUpdater; + private MockObject & GeolocationDbUpdaterInterface $dbUpdater; protected function setUp(): void { - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); - $this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater->reveal())); + $this->dbUpdater = $this->createMock(GeolocationDbUpdaterInterface::class); + $this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater)); } /** @@ -39,10 +38,8 @@ class DownloadGeoLiteDbCommandTest extends TestCase string $expectedMessage, int $expectedExitCode, ): void { - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($olderDbExists): void { - [$beforeDownload, $handleProgress] = $args; - + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( + function (callable $beforeDownload, callable $handleProgress) use ($olderDbExists): void { $beforeDownload($olderDbExists); $handleProgress(100, 50); @@ -62,7 +59,6 @@ class DownloadGeoLiteDbCommandTest extends TestCase ); self::assertStringContainsString($expectedMessage, $output); self::assertSame($expectedExitCode, $exitCode); - $checkDbUpdate->shouldHaveBeenCalledOnce(); } public function provideFailureParams(): iterable @@ -85,7 +81,9 @@ class DownloadGeoLiteDbCommandTest extends TestCase */ public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void { - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will($checkUpdateBehavior); + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( + $checkUpdateBehavior, + ); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -93,16 +91,13 @@ class DownloadGeoLiteDbCommandTest extends TestCase self::assertStringContainsString($expectedMessage, $output); self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode); - $checkDbUpdate->shouldHaveBeenCalledOnce(); } public function provideSuccessParams(): iterable { yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.']; - yield 'outdated db' => [function (array $args): GeolocationResult { - [$beforeDownload] = $args; + yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult { $beforeDownload(true); - return GeolocationResult::DB_CREATED; }, '[OK] GeoLite2 db file properly downloaded.']; } diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 6f83b8b5..90147541 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Visit; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -25,16 +24,16 @@ class GetNonOrphanVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsHelper; - private ObjectProphecy $stringifier; + private MockObject & VisitsStatsHelperInterface $visitsHelper; + private MockObject & ShortUrlStringifierInterface $stringifier; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); $this->commandTester = $this->testerForCommand( - new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier), ); } @@ -45,10 +44,10 @@ class GetNonOrphanVisitsCommandTest extends TestCase $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn( + $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn( new Paginator(new ArrayAdapter([$visit])), ); - $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn('the_short_url'); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); @@ -64,7 +63,5 @@ class GetNonOrphanVisitsCommandTest extends TestCase OUTPUT, $output, ); - $getVisits->shouldHaveBeenCalledOnce(); - $stringify->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 8c4a717d..199578f3 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Visit; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -23,12 +22,12 @@ class GetOrphanVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal())); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper)); } /** @test */ @@ -37,7 +36,7 @@ class GetOrphanVisitsCommandTest extends TestCase $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn( + $this->visitsHelper->expects($this->once())->method('orphanVisits')->withAnyParameters()->willReturn( new Paginator(new ArrayAdapter([$visit])), ); @@ -55,6 +54,5 @@ class GetOrphanVisitsCommandTest extends TestCase OUTPUT, $output, ); - $getVisits->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 2c866689..518d9f45 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Visit; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; @@ -15,12 +14,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitGeolocationHelperInterface; -use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; +use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocatorInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; @@ -35,33 +35,24 @@ class LocateVisitsCommandTest extends TestCase use CliTestUtilsTrait; private CommandTester $commandTester; - private ObjectProphecy $visitService; - private ObjectProphecy $visitToLocation; - private ObjectProphecy $lock; - private ObjectProphecy $downloadDbCommand; + private MockObject & VisitLocatorInterface $visitService; + private MockObject & VisitToLocationHelperInterface $visitToLocation; + private MockObject & Lock\LockInterface $lock; + private MockObject & Command $downloadDbCommand; protected function setUp(): void { - $this->visitService = $this->prophesize(VisitLocator::class); - $this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class); + $this->visitService = $this->createMock(VisitLocatorInterface::class); + $this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class); - $locker = $this->prophesize(Lock\LockFactory::class); - $this->lock = $this->prophesize(Lock\LockInterface::class); - $this->lock->acquire(false)->willReturn(true); - $this->lock->release()->will(function (): void { - }); - $locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal()); + $locker = $this->createMock(Lock\LockFactory::class); + $this->lock = $this->createMock(Lock\LockInterface::class); + $locker->method('createLock')->with($this->isType('string'), 600.0, false)->willReturn($this->lock); - $command = new LocateVisitsCommand( - $this->visitService->reveal(), - $this->visitToLocation->reveal(), - $locker->reveal(), - ); + $command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker); $this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME); - $this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_SUCCESS); - - $this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand->reveal()); + $this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand); } /** @@ -79,14 +70,23 @@ class LocateVisitsCommandTest extends TestCase $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior); - $locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will( - $mockMethodBehavior, - ); - $locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior); - $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willReturn( - Location::emptyInstance(), - ); + $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); + $this->visitService->expects($this->exactly($expectedUnlocatedCalls)) + ->method('locateUnlocatedVisits') + ->withAnyParameters() + ->willReturnCallback($mockMethodBehavior); + $this->visitService->expects($this->exactly($expectedEmptyCalls)) + ->method('locateVisitsWithEmptyLocation') + ->withAnyParameters() + ->willReturnCallback($mockMethodBehavior); + $this->visitService->expects($this->exactly($expectedAllCalls)) + ->method('locateAllVisits') + ->withAnyParameters() + ->willReturnCallback($mockMethodBehavior); + $this->visitToLocation->expects( + $this->exactly($expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls), + )->method('resolveVisitLocation')->withAnyParameters()->willReturn(Location::emptyInstance()); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); $this->commandTester->setInputs(['y']); $this->commandTester->execute($args); @@ -98,12 +98,6 @@ class LocateVisitsCommandTest extends TestCase } else { self::assertStringNotContainsString('Continue at your own', $output); } - $locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls); - $locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls); - $locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls); - $resolveIpLocation->shouldHaveBeenCalledTimes( - $expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls, - ); } public function provideArgs(): iterable @@ -122,18 +116,19 @@ class LocateVisitsCommandTest extends TestCase $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( - $this->invokeHelperMethods($visit, $location), - ); - $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow($e); + $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); + $this->visitService->expects($this->once()) + ->method('locateUnlocatedVisits') + ->withAnyParameters() + ->willReturnCallback($this->invokeHelperMethods($visit, $location)); + $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException($e); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('Processing IP', $output); self::assertStringContainsString($message, $output); - $locateVisits->shouldHaveBeenCalledOnce(); - $resolveIpLocation->shouldHaveBeenCalledOnce(); } public function provideIgnoredAddresses(): iterable @@ -148,28 +143,26 @@ class LocateVisitsCommandTest extends TestCase $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will( - $this->invokeHelperMethods($visit, $location), - ); - $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any())->willThrow( + $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); + $this->visitService->expects($this->once()) + ->method('locateUnlocatedVisits') + ->withAnyParameters() + ->willReturnCallback($this->invokeHelperMethods($visit, $location)); + $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->willThrowException( IpCannotBeLocatedException::forError(WrongIpException::fromIpAddress('1.2.3.4')), ); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('An error occurred while locating IP. Skipped', $output); - $locateVisits->shouldHaveBeenCalledOnce(); - $resolveIpLocation->shouldHaveBeenCalledOnce(); } private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable { - return function (array $args) use ($visit, $location): void { - /** @var VisitGeolocationHelperInterface $helper */ - [$helper] = $args; - + return static function (VisitGeolocationHelperInterface $helper) use ($visit, $location): void { $helper->geolocateVisit($visit); $helper->onVisitLocated($location, $visit); }; @@ -178,11 +171,11 @@ class LocateVisitsCommandTest extends TestCase /** @test */ public function noActionIsPerformedIfLockIsAcquired(): void { - $this->lock->acquire(false)->willReturn(false); + $this->lock->method('acquire')->with($this->isFalse())->willReturn(false); - $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function (): void { - }); - $resolveIpLocation = $this->visitToLocation->resolveVisitLocation(Argument::any()); + $this->visitService->expects($this->never())->method('locateUnlocatedVisits'); + $this->visitToLocation->expects($this->never())->method('resolveVisitLocation'); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); @@ -191,25 +184,27 @@ class LocateVisitsCommandTest extends TestCase sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME), $output, ); - $locateVisits->shouldNotHaveBeenCalled(); - $resolveIpLocation->shouldNotHaveBeenCalled(); } /** @test */ public function showsProperMessageWhenGeoLiteUpdateFails(): void { - $this->downloadDbCommand->run(Argument::cetera())->willReturn(ExitCodes::EXIT_FAILURE); + $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_FAILURE); + $this->visitService->expects($this->never())->method('locateUnlocatedVisits'); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output); - $this->visitService->locateUnlocatedVisits(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ public function providingAllFlagOnItsOwnDisplaysNotice(): void { + $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->commandTester->execute(['--all' => true]); $output = $this->commandTester->getDisplay(); @@ -222,6 +217,8 @@ class LocateVisitsCommandTest extends TestCase */ public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void { + $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Execution aborted'); diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index cb08a692..c93731c1 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -31,8 +31,8 @@ class ApplicationFactoryTest extends TestCase 'baz' => 'baz', ], ]); - $sm->setService('foo', $this->createCommandMock('foo')->reveal()); - $sm->setService('bar', $this->createCommandMock('bar')->reveal()); + $sm->setService('foo', $this->createCommandMock('foo')); + $sm->setService('bar', $this->createCommandMock('bar')); $instance = ($this->factory)($sm); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 61056922..8d6188e9 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\CLI\GeoLite; use Cake\Chronos\Chronos; use GeoIp2\Database\Reader; use MaxMind\Db\Reader\Metadata; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater; use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult; @@ -25,23 +23,16 @@ use function range; class GeolocationDbUpdaterTest extends TestCase { - use ProphecyTrait; - - private GeolocationDbUpdater $geolocationDbUpdater; - private ObjectProphecy $dbUpdater; - private ObjectProphecy $geoLiteDbReader; - private ObjectProphecy $lock; + private MockObject & DbUpdaterInterface $dbUpdater; + private MockObject & Reader $geoLiteDbReader; + private MockObject & Lock\LockInterface $lock; protected function setUp(): void { - $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); - $this->geoLiteDbReader = $this->prophesize(Reader::class); - $this->trackingOptions = new TrackingOptions(); - - $this->lock = $this->prophesize(Lock\LockInterface::class); - $this->lock->acquire(true)->willReturn(true); - $this->lock->release()->will(function (): void { - }); + $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); + $this->geoLiteDbReader = $this->createMock(Reader::class); + $this->lock = $this->createMock(Lock\LockInterface::class); + $this->lock->method('acquire')->with($this->isTrue())->willReturn(true); } /** @test */ @@ -50,25 +41,21 @@ class GeolocationDbUpdaterTest extends TestCase $mustBeUpdated = fn () => self::assertTrue(true); $prev = new DbUpdateException(''); - $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false); - $getMeta = $this->geoLiteDbReader->metadata(); - $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); + $this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with( + $this->isNull(), + )->willThrowException($prev); + $this->geoLiteDbReader->expects($this->never())->method('metadata'); try { $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); - self::assertTrue(false); // If this is reached, the test will fail + self::fail(); } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertFalse($e->olderDbExists()); } - - $fileExists->shouldHaveBeenCalledOnce(); - $getMeta->shouldNotHaveBeenCalled(); - $download->shouldHaveBeenCalledOnce(); - $this->lock->acquire(true)->shouldHaveBeenCalledOnce(); - $this->lock->release()->shouldHaveBeenCalledOnce(); } /** @@ -77,26 +64,24 @@ class GeolocationDbUpdaterTest extends TestCase */ public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void { - $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); - $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch( - Chronos::now()->subDays($days)->getTimestamp(), - )); $prev = new DbUpdateException(''); - $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->with( + $this->isNull(), + )->willThrowException($prev); + $this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn( + $this->buildMetaWithBuildEpoch(Chronos::now()->subDays($days)->getTimestamp()), + ); try { $this->geolocationDbUpdater()->checkDbUpdate(); - self::assertTrue(false); // If this is reached, the test will fail + self::fail(); } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertTrue($e->olderDbExists()); } - - $fileExists->shouldHaveBeenCalledOnce(); - $getMeta->shouldHaveBeenCalledOnce(); - $download->shouldHaveBeenCalledOnce(); } public function provideBigDays(): iterable @@ -113,17 +98,15 @@ class GeolocationDbUpdaterTest extends TestCase */ public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void { - $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); - $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch)); - $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void { - }); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->dbUpdater->expects($this->never())->method('downloadFreshCopy'); + $this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn( + $this->buildMetaWithBuildEpoch($buildEpoch), + ); $result = $this->geolocationDbUpdater()->checkDbUpdate(); self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result); - $fileExists->shouldHaveBeenCalledOnce(); - $getMeta->shouldHaveBeenCalledOnce(); - $download->shouldNotHaveBeenCalled(); } public function provideSmallDays(): iterable @@ -139,18 +122,16 @@ class GeolocationDbUpdaterTest extends TestCase /** @test */ public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void { - $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true); - $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid')); - $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void { - }); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->dbUpdater->expects($this->never())->method('downloadFreshCopy'); + $this->geoLiteDbReader->expects($this->once())->method('metadata')->with()->willReturn( + $this->buildMetaWithBuildEpoch('invalid'), + ); $this->expectException(GeolocationDbUpdateFailedException::class); $this->expectExceptionMessage( 'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.', ); - $fileExists->shouldBeCalledOnce(); - $getMeta->shouldBeCalledOnce(); - $download->shouldNotBeCalled(); $this->geolocationDbUpdater()->checkDbUpdate(); } @@ -177,10 +158,10 @@ class GeolocationDbUpdaterTest extends TestCase public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void { $result = $this->geolocationDbUpdater($options)->checkDbUpdate(); + $this->dbUpdater->expects($this->never())->method('databaseFileExists'); + $this->geoLiteDbReader->expects($this->never())->method('metadata'); self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result); - $this->dbUpdater->databaseFileExists(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->geoLiteDbReader->metadata(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideTrackingOptions(): iterable @@ -192,13 +173,13 @@ class GeolocationDbUpdaterTest extends TestCase private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater { - $locker = $this->prophesize(Lock\LockFactory::class); - $locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); + $locker = $this->createMock(Lock\LockFactory::class); + $locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock); return new GeolocationDbUpdater( - $this->dbUpdater->reveal(), - $this->geoLiteDbReader->reveal(), - $locker->reveal(), + $this->dbUpdater, + $this->geoLiteDbReader, + $locker, $options ?? new TrackingOptions(), ); } diff --git a/module/CLI/test/Util/ProcessRunnerTest.php b/module/CLI/test/Util/ProcessRunnerTest.php index 05ac5dd7..a23d1b48 100644 --- a/module/CLI/test/Util/ProcessRunnerTest.php +++ b/module/CLI/test/Util/ProcessRunnerTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Util; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Util\ProcessRunner; use Symfony\Component\Console\Helper\DebugFormatterHelper; use Symfony\Component\Console\Helper\HelperSet; @@ -17,90 +15,73 @@ use Symfony\Component\Process\Process; class ProcessRunnerTest extends TestCase { - use ProphecyTrait; - private ProcessRunner $runner; - private ObjectProphecy $helper; - private ObjectProphecy $formatter; - private ObjectProphecy $process; - private ObjectProphecy $output; + private MockObject & ProcessHelper $helper; + private MockObject & DebugFormatterHelper $formatter; + private MockObject & Process $process; + private MockObject & OutputInterface $output; protected function setUp(): void { - $this->helper = $this->prophesize(ProcessHelper::class); - $this->formatter = $this->prophesize(DebugFormatterHelper::class); - $helperSet = $this->prophesize(HelperSet::class); - $helperSet->get('debug_formatter')->willReturn($this->formatter->reveal()); - $this->helper->getHelperSet()->willReturn($helperSet->reveal()); - $this->process = $this->prophesize(Process::class); + $this->helper = $this->createMock(ProcessHelper::class); + $this->formatter = $this->createMock(DebugFormatterHelper::class); + $helperSet = $this->createMock(HelperSet::class); + $helperSet->method('get')->with('debug_formatter')->willReturn($this->formatter); + $this->helper->method('getHelperSet')->with()->willReturn($helperSet); + $this->process = $this->createMock(Process::class); + $this->output = $this->createMock(OutputInterface::class); - $this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal()); - $this->output = $this->prophesize(OutputInterface::class); + $this->runner = new ProcessRunner($this->helper, fn () => $this->process); } /** @test */ public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void { - $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false); - $isDebug = $this->output->isDebug()->willReturn(false); - $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); + $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false); + $this->output->expects($this->once())->method('isDebug')->with()->willReturn(false); + $this->output->expects($this->never())->method('write'); + $this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf(); + $this->process->expects($this->never())->method('isSuccessful'); + $this->process->expects($this->never())->method('getCommandLine'); + $this->helper->expects($this->never())->method('wrapCallback'); + $this->formatter->expects($this->never())->method('start'); + $this->formatter->expects($this->never())->method('stop'); - $this->runner->run($this->output->reveal(), []); - - $isVeryVerbose->shouldHaveBeenCalledTimes(2); - $isDebug->shouldHaveBeenCalledOnce(); - $mustRun->shouldHaveBeenCalledOnce(); - $this->process->isSuccessful()->shouldNotHaveBeenCalled(); - $this->process->getCommandLine()->shouldNotHaveBeenCalled(); - $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->runner->run($this->output, []); } /** @test */ public function someMessagesAreWrittenWhenOutputIsVerbose(): void { - $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true); - $isDebug = $this->output->isDebug()->willReturn(false); - $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); - $isSuccessful = $this->process->isSuccessful()->willReturn(true); - $getCommandLine = $this->process->getCommandLine()->willReturn('true'); - $start = $this->formatter->start(Argument::cetera())->willReturn(''); - $stop = $this->formatter->stop(Argument::cetera())->willReturn(''); + $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(true); + $this->output->expects($this->once())->method('isDebug')->with()->willReturn(false); + $this->output->expects($this->exactly(2))->method('write')->withAnyParameters(); + $this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf(); + $this->process->expects($this->exactly(2))->method('isSuccessful')->with()->willReturn(true); + $this->process->expects($this->once())->method('getCommandLine')->with()->willReturn('true'); + $this->formatter->expects($this->once())->method('start')->withAnyParameters()->willReturn(''); + $this->formatter->expects($this->once())->method('stop')->withAnyParameters()->willReturn(''); + $this->helper->expects($this->never())->method('wrapCallback'); - $this->runner->run($this->output->reveal(), []); - - $isVeryVerbose->shouldHaveBeenCalledTimes(2); - $isDebug->shouldHaveBeenCalledOnce(); - $mustRun->shouldHaveBeenCalledOnce(); - $this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2); - $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled(); - $isSuccessful->shouldHaveBeenCalledTimes(2); - $getCommandLine->shouldHaveBeenCalledOnce(); - $start->shouldHaveBeenCalledOnce(); - $stop->shouldHaveBeenCalledOnce(); + $this->runner->run($this->output, []); } /** @test */ public function wrapsCallbackWhenOutputIsDebug(): void { - $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false); - $isDebug = $this->output->isDebug()->willReturn(true); - $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal()); - $wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void { - }); + $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false); + $this->output->expects($this->once())->method('isDebug')->with()->willReturn(true); + $this->output->expects($this->never())->method('write'); + $this->process->expects($this->once())->method('mustRun')->withAnyParameters()->willReturnSelf(); + $this->process->expects($this->never())->method('isSuccessful'); + $this->process->expects($this->never())->method('getCommandLine'); + $this->helper->expects($this->once())->method('wrapCallback')->withAnyParameters()->willReturn( + function (): void { + }, + ); + $this->formatter->expects($this->never())->method('start'); + $this->formatter->expects($this->never())->method('stop'); - $this->runner->run($this->output->reveal(), []); - - $isVeryVerbose->shouldHaveBeenCalledTimes(2); - $isDebug->shouldHaveBeenCalledOnce(); - $mustRun->shouldHaveBeenCalledOnce(); - $wrapCallback->shouldHaveBeenCalledOnce(); - $this->process->isSuccessful()->shouldNotHaveBeenCalled(); - $this->process->getCommandLine()->shouldNotHaveBeenCalled(); - $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->runner->run($this->output, []); } } diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index ffe1f30d..829e56d9 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Util; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use ReflectionObject; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Symfony\Component\Console\Helper\Table; @@ -16,15 +14,13 @@ use Symfony\Component\Console\Output\OutputInterface; class ShlinkTableTest extends TestCase { - use ProphecyTrait; - private ShlinkTable $shlinkTable; - private ObjectProphecy $baseTable; + private MockObject & Table $baseTable; protected function setUp(): void { - $this->baseTable = $this->prophesize(Table::class); - $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable->reveal()); + $this->baseTable = $this->createMock(Table::class); + $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable); } /** @test */ @@ -35,29 +31,22 @@ class ShlinkTableTest extends TestCase $headerTitle = 'Header'; $footerTitle = 'Footer'; - $setStyle = $this->baseTable->setStyle(Argument::type(TableStyle::class))->willReturn( - $this->baseTable->reveal(), - ); - $setHeaders = $this->baseTable->setHeaders($headers)->willReturn($this->baseTable->reveal()); - $setRows = $this->baseTable->setRows($rows)->willReturn($this->baseTable->reveal()); - $setFooterTitle = $this->baseTable->setFooterTitle($footerTitle)->willReturn($this->baseTable->reveal()); - $setHeaderTitle = $this->baseTable->setHeaderTitle($headerTitle)->willReturn($this->baseTable->reveal()); - $render = $this->baseTable->render()->willReturn($this->baseTable->reveal()); + $this->baseTable->expects($this->once())->method('setStyle')->with( + $this->isInstanceOf(TableStyle::class), + )->willReturnSelf(); + $this->baseTable->expects($this->once())->method('setHeaders')->with($headers)->willReturnSelf(); + $this->baseTable->expects($this->once())->method('setRows')->with($rows)->willReturnSelf(); + $this->baseTable->expects($this->once())->method('setFooterTitle')->with($footerTitle)->willReturnSelf(); + $this->baseTable->expects($this->once())->method('setHeaderTitle')->with($headerTitle)->willReturnSelf(); + $this->baseTable->expects($this->once())->method('render')->with()->willReturnSelf(); $this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle); - - $setStyle->shouldHaveBeenCalledOnce(); - $setHeaders->shouldHaveBeenCalledOnce(); - $setRows->shouldHaveBeenCalledOnce(); - $setFooterTitle->shouldHaveBeenCalledOnce(); - $setHeaderTitle->shouldHaveBeenCalledOnce(); - $render->shouldHaveBeenCalledOnce(); } /** @test */ public function newTableIsCreatedForFactoryMethod(): void { - $instance = ShlinkTable::default($this->prophesize(OutputInterface::class)->reveal()); + $instance = ShlinkTable::default($this->createMock(OutputInterface::class)); $ref = new ReflectionObject($instance); $baseTable = $ref->getProperty('baseTable'); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index a7ebd1b7..48fe3cb2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Psr\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; @@ -34,6 +35,7 @@ return [ ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class, + ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class, ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, ShortUrl\Helper\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class, @@ -44,6 +46,14 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class, + ShortUrl\Repository\ShortUrlListRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], + ShortUrl\Repository\CrawlableShortCodesQuery::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], Tag\TagService::class => ConfigAbstractFactory::class, @@ -55,6 +65,10 @@ return [ Visit\Geolocation\VisitToLocationHelper::class => ConfigAbstractFactory::class, Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class, Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class, + Visit\Repository\VisitLocationRepository::class => [ + EntityRepositoryFactory::class, + Visit\Entity\Visit::class, + ], Util\UrlValidator::class => ConfigAbstractFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, @@ -109,7 +123,11 @@ return [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, ], - Visit\Geolocation\VisitLocator::class => ['em'], + ShortUrl\ShortUrlListService::class => [ + ShortUrl\Repository\ShortUrlListRepository::class, + Options\UrlShortenerOptions::class, + ], + Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitLocationRepository::class], Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class], Visit\VisitsStatsHelper::class => ['em'], Tag\TagService::class => ['em'], diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index d34175c7..e7dff2ad 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Cake\Chronos\Chronos; +use Cake\Chronos\ChronosInterface; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; @@ -35,7 +36,7 @@ function generateRandomShortCode(int $length): string function parseDateFromQuery(array $query, string $dateName): ?Chronos { - return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); + return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); } function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange @@ -46,7 +47,10 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } -function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos +/** + * @return ($date is null ? null : Chronos) + */ +function normalizeOptionalDate(string|DateTimeInterface|ChronosInterface|null $date): ?Chronos { $parsedDate = match (true) { $date === null || $date instanceof Chronos => $date, @@ -57,6 +61,11 @@ function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos return $parsedDate?->setTimezone(date_default_timezone_get()); } +function normalizeDate(string|DateTimeInterface|ChronosInterface $date): Chronos +{ + return normalizeOptionalDate($date); +} + function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int { $value = $inputFilter->getValue($fieldName); @@ -69,6 +78,12 @@ function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldN return $value !== null ? (bool) $value : null; } +function getNonEmptyOptionalValueFromInputFilter(InputFilter $inputFilter, string $fieldName): mixed +{ + $value = $inputFilter->getValue($fieldName); + return empty($value) ? null : $value; +} + function arrayToString(array $array, int $indentSize = 4): string { $indent = str_repeat(' ', $indentSize); diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php index 12baa7b3..214dc7a0 100644 --- a/module/Core/src/Action/RobotsAction.php +++ b/module/Core/src/Action/RobotsAction.php @@ -17,7 +17,7 @@ use const PHP_EOL; class RobotsAction implements RequestHandlerInterface, StatusCodeInterface { - public function __construct(private CrawlingHelperInterface $crawlingHelper) + public function __construct(private readonly CrawlingHelperInterface $crawlingHelper) { } diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php index 2c38fabd..958cb96e 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -4,20 +4,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Crawling; -use Doctrine\ORM\EntityManagerInterface; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface; class CrawlingHelper implements CrawlingHelperInterface { - public function __construct(private EntityManagerInterface $em) + public function __construct(private readonly CrawlableShortCodesQueryInterface $query) { } public function listCrawlableShortCodes(): iterable { - /** @var ShortUrlRepositoryInterface $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - yield from $repo->findCrawlableShortCodes(); + yield from ($this->query)(); } } diff --git a/module/Core/src/Crawling/CrawlingHelperInterface.php b/module/Core/src/Crawling/CrawlingHelperInterface.php index 635a4fc9..3438b2ba 100644 --- a/module/Core/src/Crawling/CrawlingHelperInterface.php +++ b/module/Core/src/Crawling/CrawlingHelperInterface.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Crawling; interface CrawlingHelperInterface { /** - * @return string[]|iterable + * @return iterable */ public function listCrawlableShortCodes(): iterable; } diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 0f28c7fa..7248e0d3 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -11,39 +11,53 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Importer\Model\ImportResult; use Shlinkio\Shlink\Importer\Params\ImportParams; use Shlinkio\Shlink\Importer\Sources\ImportSource; use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\Console\Style\StyleInterface; use Throwable; +use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; class ImportedLinksProcessor implements ImportedLinksProcessorInterface { - private ShortUrlRepositoryInterface $shortUrlRepo; - public function __construct( private readonly EntityManagerInterface $em, private readonly ShortUrlRelationResolverInterface $relationResolver, private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, private readonly DoctrineBatchHelperInterface $batchHelper, ) { - $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); + } + + public function process(StyleInterface $io, ImportResult $result, ImportParams $params): void + { + $io->title('Importing short URLs'); + $this->importShortUrls($io, $result->shlinkUrls, $params); + + if ($params->importOrphanVisits) { + $io->title('Importing orphan visits'); + $this->importOrphanVisits($io, $result->orphanVisits); + } + + $io->success('Data properly imported!'); } /** * @param iterable $shlinkUrls */ - public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void + private function importShortUrls(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void { $importShortCodes = $params->importShortCodes; $source = $params->source; $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100); - /** @var ImportedShlinkUrl $importedUrl */ foreach ($iterable as $importedUrl) { $skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf( 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate ' @@ -78,7 +92,9 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface bool $importShortCodes, callable $skipOnShortCodeConflict, ): ShortUrlImporting { - $alreadyImportedShortUrl = $this->shortUrlRepo->findOneByImportedUrl($importedUrl); + /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + $shortUrlRepo = $this->em->getRepository(ShortUrl::class); + $alreadyImportedShortUrl = $shortUrlRepo->findOneByImportedUrl($importedUrl); if ($alreadyImportedShortUrl !== null) { return ShortUrlImporting::fromExistingShortUrl($alreadyImportedShortUrl); } @@ -107,4 +123,29 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false); } + + /** + * @param iterable $orphanVisits + */ + private function importOrphanVisits(StyleInterface $io, iterable $orphanVisits): void + { + $iterable = $this->batchHelper->wrapIterable($orphanVisits, 100); + + /** @var VisitRepositoryInterface $visitRepo */ + $visitRepo = $this->em->getRepository(Visit::class); + $mostRecentOrphanVisit = $visitRepo->findMostRecentOrphanVisit(); + + $importedVisits = 0; + foreach ($iterable as $importedOrphanVisit) { + // Skip visits which are older than the most recent already imported visit's date + if ($mostRecentOrphanVisit?->getDate()->gte(normalizeDate($importedOrphanVisit->date))) { + continue; + } + + $this->em->persist(Visit::fromOrphanImport($importedOrphanVisit)); + $importedVisits++; + } + + $io->text(sprintf('Imported %s orphan visits.', $importedVisits)); + } } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index ae7f595d..0165c7c3 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Importer; -use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; +use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; final class ShortUrlImporting @@ -38,7 +38,7 @@ final class ShortUrlImporting $importedVisits = 0; foreach ($visits as $importedVisit) { // Skip visits which are older than the most recent already imported visit's date - if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date))) { + if ($mostRecentImportedDate?->gte(normalizeDate($importedVisit->date))) { continue; } diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 66607987..0ebdeb24 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -25,15 +25,17 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use function count; use function Shlinkio\Shlink\Core\generateRandomShortCode; +use function Shlinkio\Shlink\Core\normalizeDate; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; class ShortUrl extends AbstractEntity { private string $longUrl; private string $shortCode; private Chronos $dateCreated; - /** @var Collection|Visit[] */ + /** @var Collection */ private Collection $visits; - /** @var Collection|Tag[] */ + /** @var Collection */ private Collection $tags; private ?Chronos $validSince = null; private ?Chronos $validUntil = null; @@ -55,37 +57,37 @@ class ShortUrl extends AbstractEntity public static function createEmpty(): self { - return self::fromMeta(ShortUrlCreation::createEmpty()); + return self::create(ShortUrlCreation::createEmpty()); } public static function withLongUrl(string $longUrl): self { - return self::fromMeta(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); + return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); } - public static function fromMeta( - ShortUrlCreation $meta, + public static function create( + ShortUrlCreation $creation, ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { $instance = new self(); $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); - $instance->longUrl = $meta->getLongUrl(); + $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->tags = $relationResolver->resolveTags($meta->getTags()); - $instance->validSince = $meta->getValidSince(); - $instance->validUntil = $meta->getValidUntil(); - $instance->maxVisits = $meta->getMaxVisits(); - $instance->customSlugWasProvided = $meta->hasCustomSlug(); - $instance->shortCodeLength = $meta->getShortCodeLength(); - $instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); - $instance->domain = $relationResolver->resolveDomain($meta->getDomain()); - $instance->authorApiKey = $meta->getApiKey(); - $instance->title = $meta->getTitle(); - $instance->titleWasAutoResolved = $meta->titleWasAutoResolved(); - $instance->crawlable = $meta->isCrawlable(); - $instance->forwardQuery = $meta->forwardQuery(); + $instance->tags = $relationResolver->resolveTags($creation->getTags()); + $instance->validSince = $creation->getValidSince(); + $instance->validUntil = $creation->getValidUntil(); + $instance->maxVisits = $creation->getMaxVisits(); + $instance->customSlugWasProvided = $creation->hasCustomSlug(); + $instance->shortCodeLength = $creation->getShortCodeLength(); + $instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); + $instance->domain = $relationResolver->resolveDomain($creation->getDomain()); + $instance->authorApiKey = $creation->getApiKey(); + $instance->title = $creation->getTitle(); + $instance->titleWasAutoResolved = $creation->titleWasAutoResolved(); + $instance->crawlable = $creation->isCrawlable(); + $instance->forwardQuery = $creation->forwardQuery(); return $instance; } @@ -107,21 +109,13 @@ class ShortUrl extends AbstractEntity $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode; } - $instance = self::fromMeta(ShortUrlCreation::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 = self::create(ShortUrlCreation::fromRawData($meta), $relationResolver); $instance->importSource = $url->source->value; $instance->importOriginalShortCode = $url->shortCode; - $instance->dateCreated = Chronos::instance($url->createdAt); + $instance->validSince = normalizeOptionalDate($url->meta->validSince); + $instance->validUntil = normalizeOptionalDate($url->meta->validUntil); + $instance->dateCreated = normalizeDate($url->createdAt); return $instance; } @@ -147,7 +141,7 @@ class ShortUrl extends AbstractEntity } /** - * @return Collection|Tag[] + * @return Collection */ public function getTags(): Collection { @@ -174,6 +168,12 @@ class ShortUrl extends AbstractEntity return count($this->visits); } + public function nonBotVisitsCount(): int + { + $criteria = Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)); + return count($this->visits->matching($criteria)); + } + public function mostRecentImportedVisitDate(): ?Chronos { /** @var Selectable $visits */ @@ -189,7 +189,7 @@ class ShortUrl extends AbstractEntity } /** - * @param Collection|Visit[] $visits + * @param Collection $visits * @internal */ public function setVisits(Collection $visits): self diff --git a/module/Core/src/ShortUrl/Model/OrderableField.php b/module/Core/src/ShortUrl/Model/OrderableField.php new file mode 100644 index 00000000..1c1c6338 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/OrderableField.php @@ -0,0 +1,37 @@ + $field->value); + } + + public static function isBasicField(string $value): bool + { + return contains( + [self::LONG_URL->value, self::SHORT_CODE->value, self::DATE_CREATED->value, self::TITLE->value], + $value, + ); + } + + public static function isVisitsField(string $value): bool + { + return $value === self::VISITS->value || $value === self::NON_BOT_VISITS->value; + } +} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 41a95a34..bbdd9ab0 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -10,9 +10,10 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function Shlinkio\Shlink\Core\getNonEmptyOptionalValueFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\normalizeDate; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -68,13 +69,13 @@ final class ShortUrlCreation implements TitleResolutionModelInterface } $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; - $this->domain = $inputFilter->getValue(ShortUrlInputFilter::DOMAIN); + $this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN); $this->shortCodeLength = getOptionalIntFromInputFilter( $inputFilter, ShortUrlInputFilter::SHORT_CODE_LENGTH, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 13ea2961..fadc9b1e 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use function array_key_exists; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\normalizeDate; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; final class ShortUrlEdition implements TitleResolutionModelInterface { @@ -69,8 +69,8 @@ final class ShortUrlEdition implements TitleResolutionModelInterface $this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index bf760777..88e20aa7 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -10,23 +10,23 @@ use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use function Shlinkio\Shlink\Common\buildDateRange; -use function Shlinkio\Shlink\Core\normalizeDate; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; final class ShortUrlsParams { - public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits']; public const DEFAULT_ITEMS_PER_PAGE = 10; - private int $page; - private int $itemsPerPage; - private ?string $searchTerm; - private array $tags; - private TagsMode $tagsMode = TagsMode::ANY; - private Ordering $orderBy; - private ?DateRange $dateRange; - - private function __construct() - { + private function __construct( + public readonly int $page, + public readonly int $itemsPerPage, + public readonly ?string $searchTerm, + public readonly array $tags, + public readonly Ordering $orderBy, + public readonly ?DateRange $dateRange, + public readonly bool $excludeMaxVisitsReached, + public readonly bool $excludePastValidUntil, + public readonly TagsMode $tagsMode = TagsMode::ANY, + ) { } public static function emptyInstance(): self @@ -38,38 +38,31 @@ final class ShortUrlsParams * @throws ValidationException */ public static function fromRawData(array $query): self - { - $instance = new self(); - $instance->validateAndInit($query); - - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $query): void { $inputFilter = new ShortUrlsParamsInputFilter($query); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1); - $this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM); - $this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS); - $this->dateRange = buildDateRange( - normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), - normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), + return new self( + page: (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1), + itemsPerPage: (int) ( + $inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE + ), + searchTerm: $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM), + tags: (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS), + orderBy: Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)), + dateRange: buildDateRange( + normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), + normalizeOptionalDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), + ), + excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED), + excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL), + tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)), ); - $this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)); - $this->itemsPerPage = (int) ( - $inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE - ); - $this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)); } - private function resolveTagsMode(?string $rawTagsMode): TagsMode + private static function resolveTagsMode(?string $rawTagsMode): TagsMode { if ($rawTagsMode === null) { return TagsMode::ANY; @@ -77,39 +70,4 @@ final class ShortUrlsParams return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY; } - - public function page(): int - { - return $this->page; - } - - public function itemsPerPage(): int - { - return $this->itemsPerPage; - } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function tags(): array - { - return $this->tags; - } - - public function orderBy(): Ordering - { - return $this->orderBy; - } - - public function dateRange(): ?DateRange - { - return $this->dateRange; - } - - public function tagsMode(): TagsMode - { - return $this->tagsMode; - } } diff --git a/module/Core/src/ShortUrl/Model/TagsMode.php b/module/Core/src/ShortUrl/Model/TagsMode.php index 593d6d83..01cdcc3b 100644 --- a/module/Core/src/ShortUrl/Model/TagsMode.php +++ b/module/Core/src/ShortUrl/Model/TagsMode.php @@ -4,8 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Model; +use function Functional\map; + enum TagsMode: string { case ANY = 'any'; case ALL = 'all'; + + public static function values(): array + { + return map(self::cases(), static fn (TagsMode $mode) => $mode->value); + } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index a5301f21..cb120e8e 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -8,7 +8,7 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Validation; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; class ShortUrlsParamsInputFilter extends InputFilter @@ -23,6 +23,8 @@ class ShortUrlsParamsInputFilter extends InputFilter public const ITEMS_PER_PAGE = 'itemsPerPage'; public const TAGS_MODE = 'tagsMode'; public const ORDER_BY = 'orderBy'; + public const EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached'; + public const EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil'; public function __construct(array $data) { @@ -44,11 +46,14 @@ class ShortUrlsParamsInputFilter extends InputFilter $tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => [TagsMode::ALL->value, TagsMode::ANY->value], + 'haystack' => TagsMode::values(), 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); - $this->add($this->createOrderByInput(self::ORDER_BY, ShortUrlsParams::ORDERABLE_FIELDS)); + $this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values())); + + $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); + $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); } } diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index f576106f..83ce8bd9 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -8,27 +8,34 @@ use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapter implements AdapterInterface { public function __construct( - private ShortUrlRepositoryInterface $repository, - private ShortUrlsParams $params, - private ?ApiKey $apiKey, + private readonly ShortUrlListRepositoryInterface $repository, + private readonly ShortUrlsParams $params, + private readonly ?ApiKey $apiKey, + private readonly string $defaultDomain, ) { } public function getSlice(int $offset, int $length): iterable { - return $this->repository->findList( - ShortUrlsListFiltering::fromLimitsAndParams($length, $offset, $this->params, $this->apiKey), - ); + return $this->repository->findList(ShortUrlsListFiltering::fromLimitsAndParams( + $length, + $offset, + $this->params, + $this->apiKey, + $this->defaultDomain, + )); } public function getNbResults(): int { - return $this->repository->countList(ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey)); + return $this->repository->countList( + ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey, $this->defaultDomain), + ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index c4b07281..906adc63 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -9,44 +9,40 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function str_contains; +use function strtolower; + class ShortUrlsCountFiltering { + public readonly bool $searchIncludesDefaultDomain; + public function __construct( - private ?string $searchTerm = null, - private array $tags = [], - private ?TagsMode $tagsMode = null, - private ?DateRange $dateRange = null, - private ?ApiKey $apiKey = null, + public readonly ?string $searchTerm = null, + public readonly array $tags = [], + public readonly ?TagsMode $tagsMode = null, + public readonly ?DateRange $dateRange = null, + public readonly bool $excludeMaxVisitsReached = false, + public readonly bool $excludePastValidUntil = false, + public readonly ?ApiKey $apiKey = null, + ?string $defaultDomain = null, ) { + $this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains( + strtolower($defaultDomain), + strtolower($searchTerm), + ); } - public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey): self + public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, string $defaultDomain): self { - return new self($params->searchTerm(), $params->tags(), $params->tagsMode(), $params->dateRange(), $apiKey); - } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function tags(): array - { - return $this->tags; - } - - public function tagsMode(): ?TagsMode - { - return $this->tagsMode; - } - - public function dateRange(): ?DateRange - { - return $this->dateRange; - } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; + return new self( + $params->searchTerm, + $params->tags, + $params->tagsMode, + $params->dateRange, + $params->excludeMaxVisitsReached, + $params->excludePastValidUntil, + $apiKey, + $defaultDomain, + ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index 6e32d93d..db8b9a70 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -13,44 +13,49 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering { public function __construct( - private ?int $limit, - private ?int $offset, - private Ordering $orderBy, + public readonly ?int $limit, + public readonly ?int $offset, + public readonly Ordering $orderBy, ?string $searchTerm = null, array $tags = [], ?TagsMode $tagsMode = null, ?DateRange $dateRange = null, + bool $excludeMaxVisitsReached = false, + bool $excludePastValidUntil = false, ?ApiKey $apiKey = null, + ?string $defaultDomain = null, ) { - parent::__construct($searchTerm, $tags, $tagsMode, $dateRange, $apiKey); - } - - public static function fromLimitsAndParams(int $limit, int $offset, ShortUrlsParams $params, ?ApiKey $apiKey): self - { - return new self( - $limit, - $offset, - $params->orderBy(), - $params->searchTerm(), - $params->tags(), - $params->tagsMode(), - $params->dateRange(), + parent::__construct( + $searchTerm, + $tags, + $tagsMode, + $dateRange, + $excludeMaxVisitsReached, + $excludePastValidUntil, $apiKey, + $defaultDomain, ); } - public function offset(): ?int - { - return $this->offset; - } - - public function limit(): ?int - { - return $this->limit; - } - - public function orderBy(): Ordering - { - return $this->orderBy; + public static function fromLimitsAndParams( + int $limit, + int $offset, + ShortUrlsParams $params, + ?ApiKey $apiKey, + string $defaultDomain, + ): self { + return new self( + $limit, + $offset, + $params->orderBy, + $params->searchTerm, + $params->tags, + $params->tagsMode, + $params->dateRange, + $params->excludeMaxVisitsReached, + $params->excludePastValidUntil, + $apiKey, + $defaultDomain, + ); } } diff --git a/module/Core/src/ShortUrl/Repository/CrawlableShortCodesQuery.php b/module/Core/src/ShortUrl/Repository/CrawlableShortCodesQuery.php new file mode 100644 index 00000000..7b3821d8 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/CrawlableShortCodesQuery.php @@ -0,0 +1,38 @@ + + */ + public function __invoke(): 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/ShortUrl/Repository/CrawlableShortCodesQueryInterface.php b/module/Core/src/ShortUrl/Repository/CrawlableShortCodesQueryInterface.php new file mode 100644 index 00000000..9e8211e5 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/CrawlableShortCodesQueryInterface.php @@ -0,0 +1,13 @@ + + */ + public function __invoke(): iterable; +} diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php new file mode 100644 index 00000000..e014ac64 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -0,0 +1,169 @@ +createListQueryBuilder($filtering); + $qb->select('DISTINCT s') + ->setMaxResults($filtering->limit) + ->setFirstResult($filtering->offset); + + $this->processOrderByForList($qb, $filtering); + + $result = $qb->getQuery()->getResult(); + if (OrderableField::isVisitsField($filtering->orderBy->field ?? '')) { + return array_column($result, 0); + } + + return $result; + } + + private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void + { + // With no explicit order by, fallback to dateCreated-DESC + $fieldName = $filtering->orderBy->field; + if ($fieldName === null) { + $qb->orderBy('s.dateCreated', 'DESC'); + return; + } + + $order = $filtering->orderBy->direction; + + if (OrderableField::isBasicField($fieldName)) { + $qb->orderBy('s.' . $fieldName, $order); + } elseif (OrderableField::isVisitsField($fieldName)) { + // FIXME This query is inefficient. + // Diagnostic: It might need to use a sub-query, as done with the tags list query. + $qb->addSelect('COUNT(DISTINCT v)') + ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX( + $qb->expr()->eq('v.shortUrl', 's'), + $fieldName === OrderableField::NON_BOT_VISITS->value + ? $qb->expr()->eq('v.potentialBot', 'false') + : null, + )) + ->groupBy('s') + ->orderBy('COUNT(DISTINCT v)', $order); + } + } + + public function countList(ShortUrlsCountFiltering $filtering): int + { + $qb = $this->createListQueryBuilder($filtering); + $qb->select('COUNT(DISTINCT s)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(ShortUrl::class, 's') + ->where('1=1'); + + $dateRange = $filtering->dateRange; + if ($dateRange?->startDate !== null) { + $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); + $qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + if ($dateRange?->endDate !== null) { + $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); + $qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); + } + + $searchTerm = $filtering->searchTerm; + $tags = $filtering->tags; + // Apply search term to every searchable field if not empty + if (! empty($searchTerm)) { + // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later + if (empty($tags)) { + $qb->leftJoin('s.tags', 't'); + } + + // Apply general search conditions + $conditions = [ + $qb->expr()->like('s.longUrl', ':searchPattern'), + $qb->expr()->like('s.shortCode', ':searchPattern'), + $qb->expr()->like('s.title', ':searchPattern'), + $qb->expr()->like('d.authority', ':searchPattern'), + ]; + + // Include default domain in search if provided + if ($filtering->searchIncludesDefaultDomain) { + $conditions[] = $qb->expr()->isNull('s.domain'); + } + + // Apply tag conditions, only when not filtering by all provided tags + $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + if (empty($tags) || $tagsMode === TagsMode::ANY) { + $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); + } + + $qb->leftJoin('s.domain', 'd') + ->andWhere($qb->expr()->orX(...$conditions)) + ->setParameter('searchPattern', '%' . $searchTerm . '%'); + } + + // Filter by tags if provided + if (! empty($tags)) { + $tagsMode = $filtering->tagsMode ?? TagsMode::ANY; + $tagsMode === TagsMode::ANY + ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) + : $this->joinAllTags($qb, $tags); + } + + if ($filtering->excludeMaxVisitsReached) { + $qb->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.maxVisits'), + $qb->expr()->gt( + 's.maxVisits', + sprintf('(SELECT COUNT(innerV.id) FROM %s as innerV WHERE innerV.shortUrl=s)', Visit::class), + ), + )); + } + + if ($filtering->excludePastValidUntil) { + $qb + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.validUntil'), + $qb->expr()->gte('s.validUntil', ':minValidUntil'), + )) + ->setParameter('minValidUntil', Chronos::now()->toDateTimeString()); + } + + $this->applySpecification($qb, $filtering->apiKey?->spec(), 's'); + + return $qb; + } + + private function joinAllTags(QueryBuilder $qb, array $tags): void + { + foreach ($tags as $index => $tag) { + $alias = 't_' . $index; + $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index) + ->setParameter('tag' . $index, $tag); + } + } +} diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php new file mode 100644 index 00000000..130e0db7 --- /dev/null +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -0,0 +1,19 @@ +createListQueryBuilder($filtering); - $qb->select('DISTINCT s') - ->setMaxResults($filtering->limit()) - ->setFirstResult($filtering->offset()); - - // In case the ordering has been specified, the query could be more complex. Process it - if ($filtering->orderBy()->hasOrderField()) { - return $this->processOrderByForList($qb, $filtering->orderBy()); - } - - // With no explicit order by, fallback to dateCreated-DESC - return $qb->orderBy('s.dateCreated', 'DESC')->getQuery()->getResult(); - } - - private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array - { - $fieldName = $orderBy->field; - $order = $orderBy->direction; - - if ($fieldName === 'visits') { - // FIXME This query is inefficient. - // Diagnostic: It might need to use a sub-query, as done with the tags list query. - $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') - ->leftJoin('s.visits', 'v') - ->groupBy('s') - ->orderBy('totalVisits', $order); - - return array_column($qb->getQuery()->getResult(), 0); - } - - $orderableFields = ['longUrl', 'shortCode', 'dateCreated', 'title']; - if (contains($orderableFields, $fieldName)) { - $qb->orderBy('s.' . $fieldName, $order); - } - - return $qb->getQuery()->getResult(); - } - - public function countList(ShortUrlsCountFiltering $filtering): int - { - $qb = $this->createListQueryBuilder($filtering); - $qb->select('COUNT(DISTINCT s)'); - - return (int) $qb->getQuery()->getSingleScalarResult(); - } - - private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder - { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->from(ShortUrl::class, 's') - ->where('1=1'); - - $dateRange = $filtering->dateRange(); - if ($dateRange?->startDate !== null) { - $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); - } - if ($dateRange?->endDate !== null) { - $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); - } - - $searchTerm = $filtering->searchTerm(); - $tags = $filtering->tags(); - // Apply search term to every searchable field if not empty - if (! empty($searchTerm)) { - // Left join with tags only if no tags were provided. In case of tags, an inner join will be done later - if (empty($tags)) { - $qb->leftJoin('s.tags', 't'); - } - - // Apply general search conditions - $conditions = [ - $qb->expr()->like('s.longUrl', ':searchPattern'), - $qb->expr()->like('s.shortCode', ':searchPattern'), - $qb->expr()->like('s.title', ':searchPattern'), - $qb->expr()->like('d.authority', ':searchPattern'), - ]; - - // Apply tag conditions, only when not filtering by all provided tags - $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; - if (empty($tags) || $tagsMode === TagsMode::ANY) { - $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); - } - - $qb->leftJoin('s.domain', 'd') - ->andWhere($qb->expr()->orX(...$conditions)) - ->setParameter('searchPattern', '%' . $searchTerm . '%'); - } - - // Filter by tags if provided - if (! empty($tags)) { - $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; - $tagsMode === TagsMode::ANY - ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) - : $this->joinAllTags($qb, $tags); - } - - $this->applySpecification($qb, $filtering->apiKey()?->spec(), 's'); - - return $qb; - } - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at @@ -304,28 +190,4 @@ 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/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index 79cd0352..cc574ac5 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -10,16 +10,10 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findList(ShortUrlsListFiltering $filtering): array; - - public function countList(ShortUrlsCountFiltering $filtering): int; - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; @@ -31,6 +25,4 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl; public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; - - public function findCrawlableShortCodes(): iterable; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 6a58cf54..971ef932 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -21,8 +21,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** @var array */ private array $memoizedNewTags = []; - public function __construct(private EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em) { + // Registering this as an event listener will make the postFlush method to be called automatically $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } @@ -61,7 +62,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt return new Collections\ArrayCollection(map($tags, function (string $tagName) use ($repo): Tag { // Memoize only new tags, and let doctrine handle objects hydrated from persistence - $tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName); + $tag = $repo->findOneBy(['name' => $tagName]) ?? $this->memoizeNewTag($tagName); $this->em->persist($tag); return $tag; diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php new file mode 100644 index 00000000..d83647f0 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -0,0 +1,35 @@ +urlShortenerOptions->domain['hostname'] ?? ''; + $paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain)); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); + + return $paginator; + } +} diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php new file mode 100644 index 00000000..ef7b31c2 --- /dev/null +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -0,0 +1,18 @@ +em->getRepository(ShortUrl::class); - $paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params, $apiKey)); - $paginator->setMaxPerPage($params->itemsPerPage()) - ->setCurrentPage($params->page()); - - return $paginator; - } - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index 0ada5fe0..3365374e 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -4,22 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlServiceInterface { - /** - * @return ShortUrl[]|Paginator - */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; - /** * @throws ShortUrlNotFoundException * @throws InvalidUrlException diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index 262989ce..bd82cd9d 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -13,7 +13,7 @@ use function Functional\invoke_if; class ShortUrlDataTransformer implements DataTransformerInterface { - public function __construct(private ShortUrlStringifierInterface $stringifier) + public function __construct(private readonly ShortUrlStringifierInterface $stringifier) { } @@ -27,13 +27,16 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'visitsCount' => $shortUrl->getVisitsCount(), 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), 'domain' => $shortUrl->getDomain(), 'title' => $shortUrl->title(), 'crawlable' => $shortUrl->crawlable(), 'forwardQuery' => $shortUrl->forwardQuery(), + 'visitsSummary' => $this->buildVisitsSummary($shortUrl), + + // Deprecated + 'visitsCount' => $shortUrl->getVisitsCount(), ]; } @@ -49,4 +52,16 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'maxVisits' => $maxVisits, ]; } + + private function buildVisitsSummary(ShortUrl $shortUrl): array + { + $totalVisits = $shortUrl->getVisitsCount(); + $nonBotVisits = $shortUrl->nonBotVisitsCount(); + + return [ + 'total' => $totalVisits, + 'nonBots' => $nonBotVisits, + 'bots' => $totalVisits - $nonBotVisits, + ]; + } } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 4f979921..d3f54650 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -44,7 +44,7 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrl $newShortUrl */ $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { - $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); + $shortUrl = ShortUrl::create($meta, $this->relationResolver); $this->verifyShortCodeUniqueness($meta, $shortUrl); $this->em->persist($shortUrl); diff --git a/module/Core/src/Util/DoctrineBatchHelperInterface.php b/module/Core/src/Util/DoctrineBatchHelperInterface.php index 941561ed..4e0d66c4 100644 --- a/module/Core/src/Util/DoctrineBatchHelperInterface.php +++ b/module/Core/src/Util/DoctrineBatchHelperInterface.php @@ -6,5 +6,10 @@ namespace Shlinkio\Shlink\Core\Util; interface DoctrineBatchHelperInterface { + /** + * @template T + * @param iterable $resultSet + * @return iterable + */ public function wrapIterable(iterable $resultSet, int $batchSize): iterable; } diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 6451e1ba..86b56f5e 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -10,12 +10,13 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitType; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; use function Shlinkio\Shlink\Core\isCrawler; +use function Shlinkio\Shlink\Core\normalizeDate; class Visit extends AbstractEntity implements JsonSerializable { @@ -46,11 +47,30 @@ class Visit extends AbstractEntity implements JsonSerializable public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self { - $instance = new self($shortUrl, VisitType::IMPORTED); + return self::fromImportOrOrphanImport($importedVisit, VisitType::IMPORTED, $shortUrl); + } + + public static function fromOrphanImport(ImportedShlinkOrphanVisit $importedVisit): self + { + $instance = self::fromImportOrOrphanImport( + $importedVisit, + VisitType::tryFrom($importedVisit->type) ?? VisitType::IMPORTED, + ); + $instance->visitedUrl = $importedVisit->visitedUrl; + + return $instance; + } + + private static function fromImportOrOrphanImport( + ImportedShlinkVisit|ImportedShlinkOrphanVisit $importedVisit, + VisitType $type, + ?ShortUrl $shortUrl = null, + ): self { + $instance = new self($shortUrl, $type); $instance->userAgent = $importedVisit->userAgent; $instance->potentialBot = isCrawler($instance->userAgent); $instance->referer = $importedVisit->referer; - $instance->date = Chronos::instance($importedVisit->date); + $instance->date = normalizeDate($importedVisit->date); $importedLocation = $importedVisit->location; $instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null; diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 12900260..4b3b8e22 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -8,18 +8,15 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; class VisitLocator implements VisitLocatorInterface { - private VisitRepositoryInterface $repo; - - public function __construct(private EntityManagerInterface $em) - { - /** @var VisitRepositoryInterface $repo */ - $repo = $em->getRepository(Visit::class); - $this->repo = $repo; + public function __construct( + private readonly EntityManagerInterface $em, + private readonly VisitLocationRepositoryInterface $repo, + ) { } public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void diff --git a/module/Core/src/Visit/Repository/VisitLocationRepository.php b/module/Core/src/Visit/Repository/VisitLocationRepository.php new file mode 100644 index 00000000..6db1a4f8 --- /dev/null +++ b/module/Core/src/Visit/Repository/VisitLocationRepository.php @@ -0,0 +1,74 @@ + + */ + public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('v') + ->from(Visit::class, 'v') + ->where($qb->expr()->isNull('v.visitLocation')); + + return $this->visitsIterableForQuery($qb, $blockSize); + } + + /** + * @return iterable + */ + public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('v') + ->from(Visit::class, 'v') + ->join('v.visitLocation', 'vl') + ->where($qb->expr()->isNotNull('v.visitLocation')) + ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty')) + ->setParameter('isEmpty', true); + + return $this->visitsIterableForQuery($qb, $blockSize); + } + + /** + * @return iterable + */ + public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + { + $qb = $this->createQueryBuilder('v'); + return $this->visitsIterableForQuery($qb, $blockSize); + } + + private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable + { + $originalQueryBuilder = $qb->setMaxResults($blockSize) + ->orderBy('v.id', 'ASC'); + $lastId = '0'; + + do { + $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId)); + $iterator = $qb->getQuery()->toIterable(); + $resultsFound = false; + /** @var Visit|null $lastProcessedVisit */ + $lastProcessedVisit = null; + + foreach ($iterator as $key => $visit) { + $resultsFound = true; + $lastProcessedVisit = $visit; + yield $key => $visit; + } + + // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list + $lastId = $lastProcessedVisit?->getId() ?? $lastId; + } while ($resultsFound); + } +} diff --git a/module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php new file mode 100644 index 00000000..083d61f2 --- /dev/null +++ b/module/Core/src/Visit/Repository/VisitLocationRepositoryInterface.php @@ -0,0 +1,27 @@ + + */ + public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + + /** + * @return iterable + */ + public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + + /** + * @return iterable + */ + public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; +} diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 456a1118..7021e70b 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -22,65 +22,6 @@ use const PHP_INT_MAX; class VisitRepository extends EntitySpecificationRepository implements VisitRepositoryInterface { - /** - * @return iterable|Visit[] - */ - public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable - { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('v') - ->from(Visit::class, 'v') - ->where($qb->expr()->isNull('v.visitLocation')); - - return $this->visitsIterableForQuery($qb, $blockSize); - } - - /** - * @return iterable|Visit[] - */ - public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable - { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('v') - ->from(Visit::class, 'v') - ->join('v.visitLocation', 'vl') - ->where($qb->expr()->isNotNull('v.visitLocation')) - ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty')) - ->setParameter('isEmpty', true); - - return $this->visitsIterableForQuery($qb, $blockSize); - } - - public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable - { - $qb = $this->createQueryBuilder('v'); - return $this->visitsIterableForQuery($qb, $blockSize); - } - - private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable - { - $originalQueryBuilder = $qb->setMaxResults($blockSize) - ->orderBy('v.id', 'ASC'); - $lastId = '0'; - - do { - $qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId)); - $iterator = $qb->getQuery()->toIterable(); - $resultsFound = false; - /** @var Visit|null $lastProcessedVisit */ - $lastProcessedVisit = null; - - foreach ($iterator as $key => $visit) { - $resultsFound = true; - $lastProcessedVisit = $visit; - yield $key => $visit; - } - - // As the query is ordered by ID, we can take the last one every time in order to exclude the whole list - $lastId = $lastProcessedVisit?->getId() ?? $lastId; - } while ($resultsFound); - } - /** * @return Visit[] */ @@ -286,4 +227,19 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(); } + + public function findMostRecentOrphanVisit(): ?Visit + { + $dql = <<getEntityManager()->createQuery($dql); + $query->setMaxResults(1); + + return $query->getOneOrNullResult(); + } } diff --git a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php index b7052aec..4e53db2b 100644 --- a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php @@ -11,26 +11,8 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; -// TODO Split into VisitsListsRepository and VisitsLocationRepository interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public const DEFAULT_BLOCK_SIZE = 10000; - - /** - * @return iterable|Visit[] - */ - public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; - - /** - * @return iterable|Visit[] - */ - public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; - - /** - * @return iterable|Visit[] - */ - public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; - /** * @return Visit[] */ @@ -65,4 +47,6 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function findNonOrphanVisits(VisitsListFiltering $filtering): array; public function countNonOrphanVisits(VisitsCountFiltering $filtering): int; + + public function findMostRecentOrphanVisit(): ?Visit; } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 17f65abc..c96d70ff 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -129,7 +129,7 @@ class DomainRepositoryTest extends DatabaseTestCase private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl { - return ShortUrl::fromMeta( + return ShortUrl::create( ShortUrlCreation::fromRawData( ['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], ), diff --git a/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php new file mode 100644 index 00000000..04c670fa --- /dev/null +++ b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php @@ -0,0 +1,50 @@ +getEntityManager(); + $this->query = new CrawlableShortCodesQuery($em, $em->getClassMetadata(ShortUrl::class)); + } + + /** @test */ + public function invokingQueryReturnsExpectedResult(): void + { + $createShortUrl = fn (bool $crawlable) => ShortUrl::create( + ShortUrlCreation::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(); + + $results = [...($this->query)()]; + + 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/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php new file mode 100644 index 00000000..d55c301c --- /dev/null +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -0,0 +1,359 @@ +getEntityManager(); + $this->repo = new ShortUrlListRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->relationResolver = new PersistenceShortUrlRelationResolver($em); + } + + /** @test */ + public function countListReturnsProperNumberOfResults(): void + { + $count = 5; + for ($i = 0; $i < $count; $i++) { + $this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); + } + $this->getEntityManager()->flush(); + + self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); + } + + /** @test */ + public function findListProperlyFiltersResult(): void + { + $foo = ShortUrl::create( + ShortUrlCreation::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), + $this->relationResolver, + ); + $this->getEntityManager()->persist($foo); + + $bar = ShortUrl::withLongUrl('bar'); + $visits = map(range(0, 5), function () use ($bar) { + $visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); + $this->getEntityManager()->persist($visit); + + return $visit; + }); + $bar->setVisits(new ArrayCollection($visits)); + $this->getEntityManager()->persist($bar); + + $foo2 = ShortUrl::withLongUrl('foo_2'); + $visits2 = map(range(0, 3), function () use ($foo2) { + $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); + $this->getEntityManager()->persist($visit); + + return $visit; + }); + $foo2->setVisits(new ArrayCollection($visits2)); + $ref = new ReflectionObject($foo2); + $dateProp = $ref->getProperty('dateCreated'); + $dateProp->setAccessible(true); + $dateProp->setValue($foo2, Chronos::now()->subDays(5)); + $this->getEntityManager()->persist($foo2); + + $this->getEntityManager()->flush(); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']), + ); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); + self::assertSame($foo, $result[0]); + + // Assert searched text also applies to tags + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); + self::assertCount(2, $result); + self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); + self::assertContains($foo, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); + self::assertCount(3, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance())); + self::assertCount(2, $result); + + $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance())); + self::assertCount(2, $result); + + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance()))); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), + ); + self::assertCount(3, $result); + self::assertSame($bar, $result[0]); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::fromTuple( + [OrderableField::NON_BOT_VISITS->value, 'DESC'], + )), + ); + self::assertCount(3, $result); + self::assertSame($foo2, $result[0]); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( + Chronos::now()->subDays(2), + )), + ); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( + Chronos::now()->subDays(2), + )))); + self::assertSame($foo2, $result[0]); + + self::assertCount(2, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( + Chronos::now()->subDays(2), + )), + )); + self::assertEquals(2, $this->repo->countList( + new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), + )); + } + + /** @test */ + public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void + { + $urls = ['a', 'z', 'c', 'b']; + foreach ($urls as $url) { + $this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); + } + + $this->getEntityManager()->flush(); + + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])), + ); + + self::assertCount(count($urls), $result); + self::assertEquals('a', $result[0]->getLongUrl()); + self::assertEquals('b', $result[1]->getLongUrl()); + self::assertEquals('c', $result[2]->getLongUrl()); + self::assertEquals('z', $result[3]->getLongUrl()); + } + + /** @test */ + public function findListReturnsOnlyThoseWithMatchingTags(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'tags' => ['foo', 'bar'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'tags' => ['foo', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + 'tags' => ['foo'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo4', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo5', + 'tags' => ['bar', 'baz'], + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl5); + + $this->getEntityManager()->flush(); + + self::assertCount(5, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']), + )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar'], + TagsMode::ANY, + ))); + self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar'], + TagsMode::ALL, + ))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); + + self::assertCount(4, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), + )); + self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['bar', 'baz'], + TagsMode::ANY, + ))); + self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['bar', 'baz'], + TagsMode::ALL, + ))); + self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); + self::assertEquals(4, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), + )); + self::assertEquals(2, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), + )); + + self::assertCount(5, $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']), + )); + self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar', 'baz'], + TagsMode::ANY, + ))); + self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + null, + ['foo', 'bar', 'baz'], + TagsMode::ALL, + ))); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); + self::assertEquals(5, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), + )); + self::assertEquals(0, $this->repo->countList( + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), + )); + } + + /** @test */ + public function findListReturnsOnlyThoseWithMatchingDomains(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'domain' => null, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'domain' => null, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + 'domain' => 'another.com', + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + + $this->getEntityManager()->flush(); + + $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + searchTerm: $searchTerm, + defaultDomain: 'deFaulT-domain.com', + ); + + self::assertCount(2, $this->repo->findList($buildFiltering('default-dom'))); + self::assertCount(2, $this->repo->findList($buildFiltering('DOM'))); + self::assertCount(1, $this->repo->findList($buildFiltering('another'))); + self::assertCount(3, $this->repo->findList($buildFiltering('foo'))); + self::assertCount(0, $this->repo->findList($buildFiltering('no results'))); + } + + /** @test */ + public function findListReturnsOnlyThoseWithoutExcludedUrls(): void + { + $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo1', + 'validUntil' => Chronos::now()->addDays(1)->toAtomString(), + 'maxVisits' => 100, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl1); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo2', + 'validUntil' => Chronos::now()->subDays(1)->toAtomString(), + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl2); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo3', + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl3); + $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'foo4', + 'maxVisits' => 3, + ]), $this->relationResolver); + $this->getEntityManager()->persist($shortUrl4); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::emptyInstance())); + + $this->getEntityManager()->flush(); + + $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => + new ShortUrlsListFiltering( + null, + null, + Ordering::emptyInstance(), + excludeMaxVisitsReached: $excludeMaxVisitsReached, + excludePastValidUntil: $excludePastValidUntil, + ); + + self::assertCount(4, $this->repo->findList($filtering(false, false))); + self::assertEquals(4, $this->repo->countList($filtering(false, false))); + self::assertCount(3, $this->repo->findList($filtering(true, false))); + self::assertEquals(3, $this->repo->countList($filtering(true, false))); + self::assertCount(3, $this->repo->findList($filtering(false, true))); + self::assertEquals(3, $this->repo->countList($filtering(false, true))); + self::assertCount(2, $this->repo->findList($filtering(true, true))); + self::assertEquals(2, $this->repo->countList($filtering(true, true))); + } +} diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 0ba1275f..c842bcb4 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -5,21 +5,12 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; use Cake\Chronos\Chronos; -use Doctrine\Common\Collections\ArrayCollection; -use ReflectionObject; -use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; @@ -27,8 +18,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; -use function count; - class ShortUrlRepositoryTest extends DatabaseTestCase { private ShortUrlRepository $repo; @@ -43,15 +32,15 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneWithDomainFallbackReturnsProperData(): void { - $regularOne = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); + $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($regularOne); - $withDomain = ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + $withDomain = ShortUrl::create(ShortUrlCreation::fromRawData( ['domain' => 'example.com', 'customSlug' => 'domain-short-code', 'longUrl' => 'foo'], )); $this->getEntityManager()->persist($withDomain); - $withDomainDuplicatingRegular = ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( ['domain' => 'doma.in', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -86,232 +75,15 @@ class ShortUrlRepositoryTest extends DatabaseTestCase )); } - /** @test */ - public function countListReturnsProperNumberOfResults(): void - { - $count = 5; - for ($i = 0; $i < $count; $i++) { - $this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); - } - $this->getEntityManager()->flush(); - - self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); - } - - /** @test */ - public function findListProperlyFiltersResult(): void - { - $foo = ShortUrl::fromMeta( - ShortUrlCreation::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), - $this->relationResolver, - ); - $this->getEntityManager()->persist($foo); - - $bar = ShortUrl::withLongUrl('bar'); - $visit = Visit::forValidShortUrl($bar, Visitor::emptyInstance()); - $this->getEntityManager()->persist($visit); - $bar->setVisits(new ArrayCollection([$visit])); - $this->getEntityManager()->persist($bar); - - $foo2 = ShortUrl::withLongUrl('foo_2'); - $ref = new ReflectionObject($foo2); - $dateProp = $ref->getProperty('dateCreated'); - $dateProp->setAccessible(true); - $dateProp->setValue($foo2, Chronos::now()->subDays(5)); - $this->getEntityManager()->persist($foo2); - - $this->getEntityManager()->flush(); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']), - ); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); - self::assertSame($foo, $result[0]); - - // Assert searched text also applies to tags - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); - self::assertCount(2, $result); - self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); - self::assertContains($foo, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); - self::assertCount(3, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance())); - self::assertCount(2, $result); - - $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance())); - self::assertCount(2, $result); - - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance()))); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['visits', 'DESC'])), - ); - self::assertCount(3, $result); - self::assertSame($bar, $result[0]); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )), - ); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( - Chronos::now()->subDays(2), - )))); - self::assertSame($foo2, $result[0]); - - self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( - Chronos::now()->subDays(2), - )), - )); - self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), - )); - } - - /** @test */ - public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void - { - $urls = ['a', 'z', 'c', 'b']; - foreach ($urls as $url) { - $this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); - } - - $this->getEntityManager()->flush(); - - $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])), - ); - - self::assertCount(count($urls), $result); - self::assertEquals('a', $result[0]->getLongUrl()); - self::assertEquals('b', $result[1]->getLongUrl()); - self::assertEquals('c', $result[2]->getLongUrl()); - self::assertEquals('z', $result[3]->getLongUrl()); - } - - /** @test */ - public function findListReturnsOnlyThoseWithMatchingTags(): void - { - $shortUrl1 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo1', - 'tags' => ['foo', 'bar'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl1); - $shortUrl2 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo2', - 'tags' => ['foo', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo3', - 'tags' => ['foo'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl3); - $shortUrl4 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo4', - 'tags' => ['bar', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl4); - $shortUrl5 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ - 'longUrl' => 'foo5', - 'tags' => ['bar', 'baz'], - ]), $this->relationResolver); - $this->getEntityManager()->persist($shortUrl5); - - $this->getEntityManager()->flush(); - - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']), - )); - self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar'], - TagsMode::ANY, - ))); - self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar'], - TagsMode::ALL, - ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); - - self::assertCount(4, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), - )); - self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['bar', 'baz'], - TagsMode::ANY, - ))); - self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['bar', 'baz'], - TagsMode::ALL, - ))); - self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); - self::assertEquals(4, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), - )); - self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), - )); - - self::assertCount(5, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']), - )); - self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ANY, - ))); - self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( - null, - null, - Ordering::emptyInstance(), - null, - ['foo', 'bar', 'baz'], - TagsMode::ALL, - ))); - self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); - self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), - )); - self::assertEquals(0, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), - )); - } - /** @test */ public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void { - $shortUrlWithoutDomain = ShortUrl::fromMeta( + $shortUrlWithoutDomain = ShortUrl::create( ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = ShortUrl::fromMeta( + $shortUrlWithDomain = ShortUrl::create( ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -335,12 +107,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneLooksForShortUrlInProperSetOfTables(): void { - $shortUrlWithoutDomain = ShortUrl::fromMeta( + $shortUrlWithoutDomain = ShortUrl::create( ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = ShortUrl::fromMeta( + $shortUrlWithDomain = ShortUrl::create( ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -381,29 +153,29 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $start = Chronos::parse('2020-03-05 20:18:30'); $end = Chronos::parse('2021-03-05 20:18:30'); - $shortUrl = ShortUrl::fromMeta( + $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); - $shortUrl2 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])); $this->getEntityManager()->persist($shortUrl2); - $shortUrl3 = ShortUrl::fromMeta( + $shortUrl3 = ShortUrl::create( ShortUrlCreation::fromRawData(['validSince' => $start, 'validUntil' => $end, 'longUrl' => 'baz']), ); $this->getEntityManager()->persist($shortUrl3); - $shortUrl4 = ShortUrl::fromMeta( + $shortUrl4 = ShortUrl::create( ShortUrlCreation::fromRawData(['customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrl4); - $shortUrl5 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); + $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl5); - $shortUrl6 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])); + $shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl6); $this->getEntityManager()->flush(); @@ -453,15 +225,15 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo', 'tags' => $tags], ); - $shortUrl1 = ShortUrl::fromMeta($meta, $this->relationResolver); + $shortUrl1 = ShortUrl::create($meta, $this->relationResolver); $this->getEntityManager()->persist($shortUrl1); $this->getEntityManager()->flush(); - $shortUrl2 = ShortUrl::fromMeta($meta, $this->relationResolver); + $shortUrl2 = ShortUrl::create($meta, $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->flush(); - $shortUrl3 = ShortUrl::fromMeta($meta, $this->relationResolver); + $shortUrl3 = ShortUrl::create($meta, $this->relationResolver); $this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->flush(); @@ -495,7 +267,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $adminApiKey = ApiKey::create(); $this->getEntityManager()->persist($adminApiKey); - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, 'domain' => $rightDomain->getAuthority(), @@ -504,7 +276,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ]), $this->relationResolver); $this->getEntityManager()->persist($shortUrl); - $nonDomainShortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $nonDomainShortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'apiKey' => $apiKey, 'longUrl' => 'non-domain', ]), $this->relationResolver); @@ -619,37 +391,4 @@ class ShortUrlRepositoryTest extends DatabaseTestCase 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( - ShortUrlCreation::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/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index 849b768f..385f2335 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -22,6 +22,8 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase } /** + * @param int<0, max> $offset + * @param int<0, max> $length * @test * @dataProvider provideFilters */ diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index b71577b9..ce0efff9 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -77,22 +77,22 @@ class TagRepositoryTest extends DatabaseTestCase ['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey], ); - $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); + $shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); $this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); - $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags, null), $this->relationResolver); + $shortUrl2 = ShortUrl::create($metaWithTags($secondUrlTags, null), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); // One of the tags has two extra short URLs, but with no visits $this->getEntityManager()->persist( - ShortUrl::fromMeta($metaWithTags(['bar'], null), $this->relationResolver), + ShortUrl::create($metaWithTags(['bar'], null), $this->relationResolver), ); $this->getEntityManager()->persist( - ShortUrl::fromMeta($metaWithTags(['bar'], $apiKey), $this->relationResolver), + ShortUrl::create($metaWithTags(['bar'], $apiKey), $this->relationResolver), ); $this->getEntityManager()->flush(); @@ -222,13 +222,13 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); - $shortUrl = ShortUrl::fromMeta( + $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); - $shortUrl2 = ShortUrl::fromMeta( + $shortUrl2 = ShortUrl::create( ShortUrlCreation::fromRawData( ['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags], ), diff --git a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php new file mode 100644 index 00000000..77f4c1e6 --- /dev/null +++ b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php @@ -0,0 +1,63 @@ +getEntityManager(); + $this->repo = new VisitLocationRepository($em, $em->getClassMetadata(Visit::class)); + } + + /** + * @test + * @dataProvider provideBlockSize + */ + public function findVisitsReturnsProperVisits(int $blockSize): void + { + $shortUrl = ShortUrl::createEmpty(); + $this->getEntityManager()->persist($shortUrl); + + for ($i = 0; $i < 6; $i++) { + $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + + if ($i >= 2) { + $location = VisitLocation::fromGeolocation(Location::emptyInstance()); + $this->getEntityManager()->persist($location); + $visit->locate($location); + } + + $this->getEntityManager()->persist($visit); + } + $this->getEntityManager()->flush(); + + $withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize); + $unlocated = $this->repo->findUnlocatedVisits($blockSize); + $all = $this->repo->findAllVisits($blockSize); + + self::assertCount(2, [...$unlocated]); + self::assertCount(4, [...$withEmptyLocation]); + self::assertCount(6, [...$all]); + } + + public function provideBlockSize(): iterable + { + return map(range(1, 10), fn (int $value) => [$value]); + } +} diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 0eaa87e1..eb806208 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -14,20 +14,16 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; -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; -use function Functional\map; use function is_string; -use function range; use function sprintf; use function str_pad; @@ -44,52 +40,6 @@ class VisitRepositoryTest extends DatabaseTestCase $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); } - /** - * @test - * @dataProvider provideBlockSize - */ - public function findVisitsReturnsProperVisits(int $blockSize): void - { - $shortUrl = ShortUrl::createEmpty(); - $this->getEntityManager()->persist($shortUrl); - $countIterable = static function (iterable $results): int { - $resultsCount = 0; - foreach ($results as $value) { - $resultsCount++; - } - - return $resultsCount; - }; - - for ($i = 0; $i < 6; $i++) { - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); - - if ($i >= 2) { - $location = VisitLocation::fromGeolocation(Location::emptyInstance()); - $this->getEntityManager()->persist($location); - $visit->locate($location); - } - - $this->getEntityManager()->persist($visit); - } - $this->getEntityManager()->flush(); - - $withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize); - $unlocated = $this->repo->findUnlocatedVisits($blockSize); - $all = $this->repo->findAllVisits($blockSize); - - // Important! assertCount will not work here, as this iterable object loads data dynamically and the count - // is 0 if not iterated - self::assertEquals(2, $countIterable($unlocated)); - self::assertEquals(4, $countIterable($withEmptyLocation)); - self::assertEquals(6, $countIterable($all)); - } - - public function provideBlockSize(): iterable - { - return map(range(1, 10), fn (int $value) => [$value]); - } - /** @test */ public function findVisitsByShortCodeReturnsProperData(): void { @@ -213,7 +163,6 @@ class VisitRepositoryTest extends DatabaseTestCase { $foo = 'foo'; - /** @var ShortUrl $shortUrl */ $this->createShortUrlsAndVisits(false, [$foo]); $this->getEntityManager()->flush(); @@ -314,7 +263,7 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); - $shortUrl = ShortUrl::fromMeta( + $shortUrl = ShortUrl::create( ShortUrlCreation::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), $this->relationResolver, ); @@ -323,11 +272,11 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey2); - $shortUrl2 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); $this->getEntityManager()->persist($shortUrl2); $this->createVisitsForShortUrl($shortUrl2, 5); - $shortUrl3 = ShortUrl::fromMeta( + $shortUrl3 = ShortUrl::create( ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), $this->relationResolver, ); @@ -366,7 +315,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -415,7 +364,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -452,15 +401,15 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findNonOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => '1'])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '1'])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); - $shortUrl2 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => '2'])); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '2'])); $this->getEntityManager()->persist($shortUrl2); $this->createVisitsForShortUrl($shortUrl2, 4); - $shortUrl3 = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => '3'])); + $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '3'])); $this->getEntityManager()->persist($shortUrl3); $this->createVisitsForShortUrl($shortUrl3, 10); @@ -492,6 +441,24 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5))); } + /** @test */ + public function findMostRecentOrphanVisitReturnsExpectedVisit(): void + { + $this->assertNull($this->repo->findMostRecentOrphanVisit()); + + $lastVisit = Visit::forBasePath(Visitor::emptyInstance()); + $this->getEntityManager()->persist($lastVisit); + $this->getEntityManager()->flush(); + + $this->assertSame($lastVisit, $this->repo->findMostRecentOrphanVisit()); + + $lastVisit2 = Visit::forRegularNotFound(Visitor::botInstance()); + $this->getEntityManager()->persist($lastVisit2); + $this->getEntityManager()->flush(); + + $this->assertSame($lastVisit2, $this->repo->findMostRecentOrphanVisit()); + } + /** * @return array{string, string, \Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl} */ @@ -500,7 +467,7 @@ class VisitRepositoryTest extends DatabaseTestCase array $tags = [], ?ApiKey $apiKey = null, ): array { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => '', ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::API_KEY => $apiKey, @@ -512,7 +479,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->createVisitsForShortUrl($shortUrl); if ($withDomain !== false) { - $shortUrlWithDomain = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => $shortCode, 'domain' => $domain, 'longUrl' => '', diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 3d301945..b493e2cb 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\PixelResponse; use Shlinkio\Shlink\Core\Action\PixelAction; @@ -19,32 +17,29 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class PixelActionTest extends TestCase { - use ProphecyTrait; - private PixelAction $action; - private ObjectProphecy $urlResolver; - private ObjectProphecy $requestTracker; + private MockObject & ShortUrlResolverInterface $urlResolver; + private MockObject & RequestTrackerInterface $requestTracker; protected function setUp(): void { - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->requestTracker = $this->createMock(RequestTrackerInterface::class); - $this->action = new PixelAction($this->urlResolver->reveal(), $this->requestTracker->reveal()); + $this->action = new PixelAction($this->urlResolver, $this->requestTracker); } /** @test */ public function imageIsReturned(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl( + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) - ->shouldBeCalledOnce(); - $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldBeCalledOnce(); + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->withAnyParameters(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); + $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); self::assertInstanceOf(PixelResponse::class, $response); self::assertEquals(200, $response->getStatusCode()); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 90635a3c..89c105c0 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\NullLogger; @@ -29,50 +27,43 @@ use function imagecreatefromstring; class QrCodeActionTest extends TestCase { - use ProphecyTrait; - private const WHITE = 0xFFFFFF; private const BLACK = 0x0; - private ObjectProphecy $urlResolver; + private MockObject & ShortUrlResolverInterface $urlResolver; protected function setUp(): void { - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); } /** @test */ public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) - ->willThrow(ShortUrlNotFoundException::class) - ->shouldBeCalledOnce(); - $delegate = $this->prophesize(RequestHandlerInterface::class); - $process = $delegate->handle(Argument::any())->willReturn(new Response()); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), + )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); + $delegate = $this->createMock(RequestHandlerInterface::class); + $delegate->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal()); - - $process->shouldHaveBeenCalledOnce(); + $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); } /** @test */ public function aCorrectRequestReturnsTheQrCodeResponse(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) - ->willReturn(ShortUrl::createEmpty()) - ->shouldBeCalledOnce(); - $delegate = $this->prophesize(RequestHandlerInterface::class); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), + )->willReturn(ShortUrl::createEmpty()); + $delegate = $this->createMock(RequestHandlerInterface::class); + $delegate->expects($this->never())->method('handle'); - $resp = $this->action()->process( - (new ServerRequest())->withAttribute('shortCode', $shortCode), - $delegate->reveal(), - ); + $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); self::assertInstanceOf(QrCodeResponse::class, $resp); self::assertEquals(200, $resp->getStatusCode()); - $delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0); } /** @@ -85,13 +76,13 @@ class QrCodeActionTest extends TestCase string $expectedContentType, ): void { $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( - ShortUrl::createEmpty(), - ); - $delegate = $this->prophesize(RequestHandlerInterface::class); + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), + )->willReturn(ShortUrl::createEmpty()); + $delegate = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate->reveal()); + $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); } @@ -118,14 +109,16 @@ class QrCodeActionTest extends TestCase int $expectedSize, ): void { $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( - ShortUrl::createEmpty(), - ); - $delegate = $this->prophesize(RequestHandlerInterface::class); + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), + )->willReturn(ShortUrl::createEmpty()); + $delegate = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate->reveal()); - [$size] = getimagesizefromstring($resp->getBody()->__toString()); + $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); + $result = getimagesizefromstring($resp->getBody()->__toString()); + self::assertNotFalse($result); + [$size] = $result; self::assertEquals($expectedSize, $size); } @@ -209,15 +202,16 @@ class QrCodeActionTest extends TestCase ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) ->withAttribute('shortCode', $code); - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( - ShortUrl::withLongUrl('https://shlink.io'), - ); - $delegate = $this->prophesize(RequestHandlerInterface::class); + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $delegate = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req, $delegate->reveal()); + $resp = $this->action($defaultOptions)->process($req, $delegate); $image = imagecreatefromstring($resp->getBody()->__toString()); - $color = imagecolorat($image, 1, 1); + self::assertNotFalse($image); + $color = imagecolorat($image, 1, 1); self::assertEquals($color, $expectedColor); } @@ -246,7 +240,7 @@ class QrCodeActionTest extends TestCase public function action(?QrCodeOptions $options = null): QrCodeAction { return new QrCodeAction( - $this->urlResolver->reveal(), + $this->urlResolver, new ShortUrlStringifier(['domain' => 'doma.in']), new NullLogger(), $options ?? new QrCodeOptions(), diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index f7449ad9..7e4d1cb0 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; @@ -22,29 +20,27 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class RedirectActionTest extends TestCase { - use ProphecyTrait; - private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; private RedirectAction $action; - private ObjectProphecy $urlResolver; - private ObjectProphecy $requestTracker; - private ObjectProphecy $redirectRespHelper; + private MockObject & ShortUrlResolverInterface $urlResolver; + private MockObject & RequestTrackerInterface $requestTracker; + private MockObject & RedirectResponseHelperInterface $redirectRespHelper; protected function setUp(): void { - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); - $this->redirectRespHelper = $this->prophesize(RedirectResponseHelperInterface::class); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->requestTracker = $this->createMock(RequestTrackerInterface::class); + $this->redirectRespHelper = $this->createMock(RedirectResponseHelperInterface::class); - $redirectBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); - $redirectBuilder->buildShortUrlRedirect(Argument::cetera())->willReturn(self::LONG_URL); + $redirectBuilder = $this->createMock(ShortUrlRedirectionBuilderInterface::class); + $redirectBuilder->method('buildShortUrlRedirect')->withAnyParameters()->willReturn(self::LONG_URL); $this->action = new RedirectAction( - $this->urlResolver->reveal(), - $this->requestTracker->reveal(), - $redirectBuilder->reveal(), - $this->redirectRespHelper->reveal(), + $this->urlResolver, + $this->requestTracker, + $redirectBuilder, + $this->redirectRespHelper, ); } @@ -53,38 +49,34 @@ class RedirectActionTest extends TestCase { $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); - $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn($shortUrl); - $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { - }); + $this->requestTracker->expects($this->once())->method('trackIfApplicable'); $expectedResp = new Response\RedirectResponse(self::LONG_URL); - $buildResp = $this->redirectRespHelper->buildRedirectResponse(self::LONG_URL)->willReturn($expectedResp); + $this->redirectRespHelper->expects($this->once())->method('buildRedirectResponse')->with( + self::LONG_URL, + )->willReturn($expectedResp); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); + $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); self::assertSame($expectedResp, $response); - $buildResp->shouldHaveBeenCalledOnce(); - $shortCodeToUrl->shouldHaveBeenCalledOnce(); - $track->shouldHaveBeenCalledOnce(); } /** @test */ public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) - ->willThrow(ShortUrlNotFoundException::class) - ->shouldBeCalledOnce(); - $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotBeCalled(); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), + )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); + $this->requestTracker->expects($this->never())->method('trackIfApplicable'); - $handler = $this->prophesize(RequestHandlerInterface::class); - $handle = $handler->handle(Argument::any())->willReturn(new Response()); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); - $this->action->process($request, $handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); + $this->action->process($request, $handler); } } diff --git a/module/Core/test/Action/RobotsActionTest.php b/module/Core/test/Action/RobotsActionTest.php index ad8a02d1..db1f7f90 100644 --- a/module/Core/test/Action/RobotsActionTest.php +++ b/module/Core/test/Action/RobotsActionTest.php @@ -5,23 +5,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Action\RobotsAction; use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface; class RobotsActionTest extends TestCase { - use ProphecyTrait; - private RobotsAction $action; - private ObjectProphecy $helper; + private MockObject & CrawlingHelperInterface $helper; protected function setUp(): void { - $this->helper = $this->prophesize(CrawlingHelperInterface::class); - $this->action = new RobotsAction($this->helper->reveal()); + $this->helper = $this->createMock(CrawlingHelperInterface::class); + $this->action = new RobotsAction($this->helper); } /** @@ -30,14 +27,16 @@ class RobotsActionTest extends TestCase */ public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void { - $getShortCodes = $this->helper->listCrawlableShortCodes()->willReturn($shortCodes); + $this->helper + ->expects($this->once()) + ->method('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 diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 912e17a5..d2d03807 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -9,10 +9,8 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use Psr\Http\Server\MiddlewareInterface; @@ -25,15 +23,13 @@ use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; class NotFoundRedirectResolverTest extends TestCase { - use ProphecyTrait; - private NotFoundRedirectResolver $resolver; - private ObjectProphecy $helper; + private MockObject & RedirectResponseHelperInterface $helper; protected function setUp(): void { - $this->helper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->resolver = new NotFoundRedirectResolver($this->helper->reveal(), new NullLogger()); + $this->helper = $this->createMock(RedirectResponseHelperInterface::class); + $this->resolver = new NotFoundRedirectResolver($this->helper, new NullLogger()); } /** @@ -47,12 +43,13 @@ class NotFoundRedirectResolverTest extends TestCase string $expectedRedirectTo, ): void { $expectedResp = new Response(); - $buildResp = $this->helper->buildRedirectResponse($expectedRedirectTo)->willReturn($expectedResp); + $this->helper->expects($this->once())->method('buildRedirectResponse')->with($expectedRedirectTo)->willReturn( + $expectedResp, + ); $resp = $this->resolver->resolveRedirectResponse($notFoundType, $redirectConfig, $uri); self::assertSame($expectedResp, $resp); - $buildResp->shouldHaveBeenCalledOnce(); } public function provideRedirects(): iterable @@ -119,11 +116,11 @@ class NotFoundRedirectResolverTest extends TestCase public function noResponseIsReturnedIfNoConditionsMatch(): void { $notFoundType = $this->notFoundType($this->requestForRoute('foo')); + $this->helper->expects($this->never())->method('buildRedirectResponse'); $result = $this->resolver->resolveRedirectResponse($notFoundType, new NotFoundRedirectOptions(), new Uri()); self::assertNull($result); - $this->helper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); } private function notFoundType(ServerRequestInterface $req): NotFoundType @@ -139,7 +136,7 @@ class NotFoundRedirectResolverTest extends TestCase RouteResult::fromRoute( new Route( '', - $this->prophesize(MiddlewareInterface::class)->reveal(), + $this->createMock(MiddlewareInterface::class), ['GET'], $routeName, ), diff --git a/module/Core/test/Crawling/CrawlingHelperTest.php b/module/Core/test/Crawling/CrawlingHelperTest.php index 92901efc..295b7ec3 100644 --- a/module/Core/test/Crawling/CrawlingHelperTest.php +++ b/module/Core/test/Crawling/CrawlingHelperTest.php @@ -4,40 +4,26 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Crawling; -use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Crawling\CrawlingHelper; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface; class CrawlingHelperTest extends TestCase { - use ProphecyTrait; - private CrawlingHelper $helper; - private ObjectProphecy $em; + private MockObject & CrawlableShortCodesQueryInterface $query; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->helper = new CrawlingHelper($this->em->reveal()); + $this->query = $this->createMock(CrawlableShortCodesQueryInterface::class); + $this->helper = new CrawlingHelper($this->query); } /** @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(); + $this->query->expects($this->once())->method('__invoke')->willReturn([]); + [...$this->helper->listCrawlableShortCodes()]; } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index a4ec22f3..46d3e567 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Domain; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; @@ -22,15 +20,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainServiceTest extends TestCase { - use ProphecyTrait; - private DomainService $domainService; - private ObjectProphecy $em; + private MockObject & EntityManagerInterface $em; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em->reveal(), 'default.com'); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->domainService = new DomainService($this->em, 'default.com'); } /** @@ -39,23 +35,23 @@ class DomainServiceTest extends TestCase */ public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void { - $repo = $this->prophesize(DomainRepositoryInterface::class); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $findDomains = $repo->findDomains($apiKey)->willReturn($domains); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $result = $this->domainService->listDomains($apiKey); self::assertEquals($expectedResult, $result); - $getRepo->shouldHaveBeenCalledOnce(); - $findDomains->shouldHaveBeenCalledOnce(); } public function provideExcludedDomains(): iterable { $default = DomainItem::forDefaultDomain('default.com', new EmptyNotFoundRedirectConfig()); $adminApiKey = ApiKey::create(); + $domain = Domain::withAuthority(''); + $domain->setId('123'); $domainSpecificApiKey = ApiKey::fromMeta( - ApiKeyMeta::withRoles(RoleDefinition::forDomain(Domain::withAuthority('')->setId('123'))), + ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain)), ); yield 'empty list without API key' => [[], [$default], null]; @@ -109,10 +105,9 @@ class DomainServiceTest extends TestCase /** @test */ public function getDomainThrowsExceptionWhenDomainIsNotFound(): void { - $find = $this->em->find(Domain::class, '123')->willReturn(null); + $this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn(null); $this->expectException(DomainNotFoundException::class); - $find->shouldBeCalledOnce(); $this->domainService->getDomain('123'); } @@ -121,12 +116,11 @@ class DomainServiceTest extends TestCase public function getDomainReturnsEntityWhenFound(): void { $domain = Domain::withAuthority(''); - $find = $this->em->find(Domain::class, '123')->willReturn($domain); + $this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn($domain); $result = $this->domainService->getDomain('123'); self::assertSame($domain, $result); - $find->shouldHaveBeenCalledOnce(); } /** @@ -136,36 +130,35 @@ class DomainServiceTest extends TestCase public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); - $flush = $this->em->flush(); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( + $foundDomain, + ); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); + $this->em->expects($this->once())->method('flush'); $result = $this->domainService->getOrCreate($authority, $apiKey); if ($foundDomain !== null) { self::assertSame($result, $foundDomain); } - $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); } /** @test */ public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void { $authority = 'example.com'; - $domain = Domain::withAuthority($authority)->setId('1'); + $domain = Domain::withAuthority($authority); + $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); - $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneByAuthority($authority, $apiKey)->willReturn(null); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->never())->method('flush'); $this->expectException(DomainNotFoundException::class); - $getRepo->shouldBeCalledOnce(); - $this->em->persist(Argument::cetera())->shouldNotBeCalled(); - $this->em->flush()->shouldNotBeCalled(); $this->domainService->getOrCreate($authority, $apiKey); } @@ -177,11 +170,11 @@ class DomainServiceTest extends TestCase public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->prophesize(DomainRepositoryInterface::class); - $repo->findOneByAuthority($authority, $apiKey)->willReturn($foundDomain); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - $persist = $this->em->persist($foundDomain ?? Argument::type(Domain::class)); - $flush = $this->em->flush(); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); + $this->em->expects($this->once())->method('flush'); $result = $this->domainService->configureNotFoundRedirects($authority, NotFoundRedirects::withRedirects( 'foo.com', @@ -195,9 +188,6 @@ class DomainServiceTest extends TestCase self::assertEquals('foo.com', $result->baseUrlRedirect()); self::assertEquals('bar.com', $result->regular404Redirect()); self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); - $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); } public function provideFoundDomains(): iterable diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index d08073e2..c6debfb5 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -22,31 +20,25 @@ use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; class NotFoundRedirectHandlerTest extends TestCase { - use ProphecyTrait; - private NotFoundRedirectHandler $middleware; private NotFoundRedirectOptions $redirectOptions; - private ObjectProphecy $resolver; - private ObjectProphecy $domainService; - private ObjectProphecy $next; + private MockObject & NotFoundRedirectResolverInterface $resolver; + private MockObject & DomainServiceInterface $domainService; + private MockObject & RequestHandlerInterface $next; private ServerRequestInterface $req; protected function setUp(): void { $this->redirectOptions = new NotFoundRedirectOptions(); - $this->resolver = $this->prophesize(NotFoundRedirectResolverInterface::class); - $this->domainService = $this->prophesize(DomainServiceInterface::class); + $this->resolver = $this->createMock(NotFoundRedirectResolverInterface::class); + $this->domainService = $this->createMock(DomainServiceInterface::class); - $this->middleware = new NotFoundRedirectHandler( - $this->redirectOptions, - $this->resolver->reveal(), - $this->domainService->reveal(), - ); + $this->middleware = new NotFoundRedirectHandler($this->redirectOptions, $this->resolver, $this->domainService); - $this->next = $this->prophesize(RequestHandlerInterface::class); + $this->next = $this->createMock(RequestHandlerInterface::class); $this->req = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, - $this->prophesize(NotFoundType::class)->reveal(), + $this->createMock(NotFoundType::class), ); } @@ -59,40 +51,47 @@ class NotFoundRedirectHandlerTest extends TestCase $expectedResp = new Response(); $setUp($this->domainService, $this->resolver); - $handle = $this->next->handle($this->req)->willReturn($expectedResp); + $this->next->expects($this->once())->method('handle')->with($this->req)->willReturn($expectedResp); - $result = $this->middleware->process($this->req, $this->next->reveal()); + $result = $this->middleware->process($this->req, $this->next); self::assertSame($expectedResp, $result); - $handle->shouldHaveBeenCalledOnce(); } public function provideNonRedirectScenarios(): iterable { - yield 'no domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { - $domainService->findByAuthority(Argument::cetera()) - ->willReturn(null) - ->shouldBeCalledOnce(); - $resolver->resolveRedirectResponse( - Argument::type(NotFoundType::class), - Argument::type(NotFoundRedirectOptions::class), - Argument::type(UriInterface::class), - )->willReturn(null)->shouldBeCalledOnce(); + yield 'no domain' => [function ( + MockObject&DomainServiceInterface $domainService, + MockObject&NotFoundRedirectResolverInterface $resolver, + ): void { + $domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn( + null, + ); + $resolver->expects($this->once())->method('resolveRedirectResponse')->with( + $this->isInstanceOf(NotFoundType::class), + $this->isInstanceOf(NotFoundRedirectOptions::class), + $this->isInstanceOf(UriInterface::class), + )->willReturn(null); }]; - yield 'non-redirecting domain' => [function (ObjectProphecy $domainService, ObjectProphecy $resolver): void { - $domainService->findByAuthority(Argument::cetera()) - ->willReturn(Domain::withAuthority('')) - ->shouldBeCalledOnce(); - $resolver->resolveRedirectResponse( - Argument::type(NotFoundType::class), - Argument::type(NotFoundRedirectOptions::class), - Argument::type(UriInterface::class), - )->willReturn(null)->shouldBeCalledOnce(); - $resolver->resolveRedirectResponse( - Argument::type(NotFoundType::class), - Argument::type(Domain::class), - Argument::type(UriInterface::class), - )->willReturn(null)->shouldBeCalledOnce(); + yield 'non-redirecting domain' => [function ( + MockObject&DomainServiceInterface $domainService, + MockObject&NotFoundRedirectResolverInterface $resolver, + ): void { + $domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn( + Domain::withAuthority(''), + ); + $resolver->expects($this->exactly(2))->method('resolveRedirectResponse')->withConsecutive( + [ + $this->isInstanceOf(NotFoundType::class), + $this->isInstanceOf(Domain::class), + $this->isInstanceOf(UriInterface::class), + ], + [ + $this->isInstanceOf(NotFoundType::class), + $this->isInstanceOf(NotFoundRedirectOptions::class), + $this->isInstanceOf(UriInterface::class), + ], + )->willReturn(null); }]; } @@ -101,19 +100,17 @@ class NotFoundRedirectHandlerTest extends TestCase { $expectedResp = new Response(); - $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn(null); - $resolveRedirect = $this->resolver->resolveRedirectResponse( - Argument::type(NotFoundType::class), + $this->domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn(null); + $this->resolver->expects($this->once())->method('resolveRedirectResponse')->with( + $this->isInstanceOf(NotFoundType::class), $this->redirectOptions, - Argument::type(UriInterface::class), + $this->isInstanceOf(UriInterface::class), )->willReturn($expectedResp); + $this->next->expects($this->never())->method('handle'); - $result = $this->middleware->process($this->req, $this->next->reveal()); + $result = $this->middleware->process($this->req, $this->next); self::assertSame($expectedResp, $result); - $findDomain->shouldHaveBeenCalledOnce(); - $resolveRedirect->shouldHaveBeenCalledOnce(); - $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ @@ -122,18 +119,18 @@ class NotFoundRedirectHandlerTest extends TestCase $expectedResp = new Response(); $domain = Domain::withAuthority(''); - $findDomain = $this->domainService->findByAuthority(Argument::cetera())->willReturn($domain); - $resolveRedirect = $this->resolver->resolveRedirectResponse( - Argument::type(NotFoundType::class), + $this->domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn( $domain, - Argument::type(UriInterface::class), + ); + $this->resolver->expects($this->once())->method('resolveRedirectResponse')->with( + $this->isInstanceOf(NotFoundType::class), + $domain, + $this->isInstanceOf(UriInterface::class), )->willReturn($expectedResp); + $this->next->expects($this->never())->method('handle'); - $result = $this->middleware->process($this->req, $this->next->reveal()); + $result = $this->middleware->process($this->req, $this->next); self::assertSame($expectedResp, $result); - $findDomain->shouldHaveBeenCalledOnce(); - $resolveRedirect->shouldHaveBeenCalledOnce(); - $this->next->handle(Argument::cetera())->shouldNotHaveBeenCalled(); } } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index 12865171..aa6302a3 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -56,7 +56,7 @@ class NotFoundTemplateHandlerTest extends TestCase RouteResult::fromRoute( new Route( '', - $this->prophesize(MiddlewareInterface::class)->reveal(), + $this->createMock(MiddlewareInterface::class), ['GET'], RedirectAction::class, ), diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 81fef1a6..d8687223 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -4,12 +4,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; -use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; @@ -18,35 +15,29 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; class NotFoundTrackerMiddlewareTest extends TestCase { - use ProphecyTrait; - private NotFoundTrackerMiddleware $middleware; private ServerRequestInterface $request; - private ObjectProphecy $requestTracker; - private ObjectProphecy $notFoundType; - private ObjectProphecy $handler; + private MockObject & RequestHandlerInterface $handler; + private MockObject & RequestTrackerInterface $requestTracker; protected function setUp(): void { - $this->notFoundType = $this->prophesize(NotFoundType::class); - $this->handler = $this->prophesize(RequestHandlerInterface::class); - $this->handler->handle(Argument::cetera())->willReturn(new Response()); - - $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); - $this->middleware = new NotFoundTrackerMiddleware($this->requestTracker->reveal()); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->requestTracker = $this->createMock(RequestTrackerInterface::class); + $this->middleware = new NotFoundTrackerMiddleware($this->requestTracker); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, - $this->notFoundType->reveal(), + $this->createMock(NotFoundType::class), ); } /** @test */ public function delegatesIntoRequestTracker(): void { - $this->middleware->process($this->request, $this->handler->reveal()); + $this->handler->expects($this->once())->method('handle')->with($this->request); + $this->requestTracker->expects($this->once())->method('trackNotFoundIfApplicable')->with($this->request); - $this->requestTracker->trackNotFoundIfApplicable($this->request)->shouldHaveBeenCalledOnce(); - $this->handler->handle($this->request)->shouldHaveBeenCalledOnce(); + $this->middleware->process($this->request, $this->handler); } } diff --git a/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php index c5d9be79..a58e3713 100644 --- a/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTypeResolverMiddlewareTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; @@ -18,30 +16,28 @@ use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTypeResolverMiddleware; class NotFoundTypeResolverMiddlewareTest extends TestCase { - use ProphecyTrait; - private NotFoundTypeResolverMiddleware $middleware; - private ObjectProphecy $handler; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { $this->middleware = new NotFoundTypeResolverMiddleware(''); - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); } /** @test */ public function notFoundTypeIsAddedToRequest(): void { $request = ServerRequestFactory::fromGlobals(); - $handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) { - Assert::assertArrayHasKey(NotFoundType::class, $req->getAttributes()); + $this->handler->expects($this->once())->method('handle')->with( + $this->callback(function (ServerRequestInterface $req): bool { + Assert::assertArrayHasKey(NotFoundType::class, $req->getAttributes()); + return true; + }), + )->willReturn(new Response()); - return true; - }))->willReturn(new Response()); - - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware->process($request, $this->handler); self::assertArrayNotHasKey(NotFoundType::class, $request->getAttributes()); - $handle->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php index b826802b..7cad7732 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -4,23 +4,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface; use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListenerDelegator; class CloseDbConnectionEventListenerDelegatorTest extends TestCase { - use ProphecyTrait; - private CloseDbConnectionEventListenerDelegator $delegator; - private ObjectProphecy $container; + private MockObject & ContainerInterface $container; protected function setUp(): void { - $this->container = $this->prophesize(ContainerInterface::class); + $this->container = $this->createMock(ContainerInterface::class); $this->delegator = new CloseDbConnectionEventListenerDelegator(); } @@ -35,12 +32,12 @@ class CloseDbConnectionEventListenerDelegatorTest extends TestCase }; }; - $em = $this->prophesize(ReopeningEntityManagerInterface::class); - $getEm = $this->container->get('em')->willReturn($em->reveal()); + $this->container->expects($this->once())->method('get')->with('em')->willReturn( + $this->createMock(ReopeningEntityManagerInterface::class), + ); - ($this->delegator)($this->container->reveal(), '', $callback); + ($this->delegator)($this->container, '', $callback); self::assertTrue($callbackInvoked); - $getEm->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index 7c4d74c8..430f08a9 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\DBAL\Connection; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface; use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListener; @@ -16,13 +15,11 @@ use Throwable; class CloseDbConnectionEventListenerTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $em; + private MockObject & ReopeningEntityManagerInterface $em; protected function setUp(): void { - $this->em = $this->prophesize(ReopeningEntityManagerInterface::class); + $this->em = $this->createMock(ReopeningEntityManagerInterface::class); } /** @@ -31,16 +28,14 @@ class CloseDbConnectionEventListenerTest extends TestCase */ public function connectionIsOpenedBeforeAndClosedAfter(callable $wrapped, bool &$wrappedWasCalled): void { - $conn = $this->prophesize(Connection::class); - $close = $conn->close()->will(function (): void { - }); - $getConn = $this->em->getConnection()->willReturn($conn->reveal()); - $close = $this->em->close()->will(function (): void { - }); - $open = $this->em->open()->will(function (): void { - }); + $conn = $this->createMock(Connection::class); + $conn->expects($this->once())->method('close'); - $eventListener = new CloseDbConnectionEventListener($this->em->reveal(), $wrapped); + $this->em->expects($this->once())->method('getConnection')->willReturn($conn); + $this->em->expects($this->once())->method('close'); + $this->em->expects($this->once())->method('open'); + + $eventListener = new CloseDbConnectionEventListener($this->em, $wrapped); try { ($eventListener)(new stdClass()); @@ -49,25 +44,21 @@ class CloseDbConnectionEventListenerTest extends TestCase } self::assertTrue($wrappedWasCalled); - $close->shouldHaveBeenCalledOnce(); - $getConn->shouldHaveBeenCalledOnce(); - $close->shouldHaveBeenCalledOnce(); - $open->shouldHaveBeenCalledOnce(); } public function provideWrapped(): iterable { - yield 'does not throw exception' => (function (): array { + yield 'does not throw exception' => (static function (): array { $wrappedWasCalled = false; - $wrapped = function () use (&$wrappedWasCalled): void { + $wrapped = static function () use (&$wrappedWasCalled): void { $wrappedWasCalled = true; }; return [$wrapped, &$wrappedWasCalled]; })(); - yield 'throws exception' => (function (): array { + yield 'throws exception' => (static function (): array { $wrappedWasCalled = false; - $wrapped = function () use (&$wrappedWasCalled): void { + $wrapped = static function () use (&$wrappedWasCalled): void { $wrappedWasCalled = true; throw new RuntimeException('Some error'); }; diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php index 5eb97592..7315e286 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\EventDispatcher\Event\GeoLiteDbCreated; use Shlinkio\Shlink\Core\EventDispatcher\LocateUnlocatedVisits; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -17,25 +16,23 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; class LocateUnlocatedVisitsTest extends TestCase { - use ProphecyTrait; - private LocateUnlocatedVisits $listener; - private ObjectProphecy $locator; - private ObjectProphecy $visitToLocation; + private MockObject & VisitLocatorInterface $locator; + private MockObject & VisitToLocationHelperInterface $visitToLocation; protected function setUp(): void { - $this->locator = $this->prophesize(VisitLocatorInterface::class); - $this->visitToLocation = $this->prophesize(VisitToLocationHelperInterface::class); + $this->locator = $this->createMock(VisitLocatorInterface::class); + $this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class); - $this->listener = new LocateUnlocatedVisits($this->locator->reveal(), $this->visitToLocation->reveal()); + $this->listener = new LocateUnlocatedVisits($this->locator, $this->visitToLocation); } /** @test */ public function locatorIsCalledWhenInvoked(): void { + $this->locator->expects($this->once())->method('locateUnlocatedVisits')->with($this->listener); ($this->listener)(new GeoLiteDbCreated()); - $this->locator->locateUnlocatedVisits($this->listener)->shouldHaveBeenCalledOnce(); } /** @test */ @@ -44,11 +41,12 @@ class LocateUnlocatedVisitsTest extends TestCase $visit = Visit::forBasePath(Visitor::emptyInstance()); $location = Location::emptyInstance(); - $resolve = $this->visitToLocation->resolveVisitLocation($visit)->willReturn($location); + $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->with($visit)->willReturn( + $location, + ); $result = $this->listener->geolocateVisit($visit); self::assertSame($location, $result); - $resolve->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index 068df028..cad6d164 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use OutOfRangeException; +use PHPUnit\Framework\MockObject\MockObject; 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\Common\Util\IpAddress; @@ -27,31 +25,27 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; class LocateVisitTest extends TestCase { - use ProphecyTrait; - private LocateVisit $locateVisit; - private ObjectProphecy $ipLocationResolver; - private ObjectProphecy $em; - private ObjectProphecy $logger; - private ObjectProphecy $dbUpdater; - private ObjectProphecy $eventDispatcher; + private MockObject & IpLocationResolverInterface $ipLocationResolver; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; + private MockObject & EventDispatcherInterface $eventDispatcher; + private MockObject & DbUpdaterInterface $dbUpdater; protected function setUp(): void { - $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); - $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); - - $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); - $this->dbUpdater->databaseFileExists()->willReturn(true); + $this->ipLocationResolver = $this->createMock(IpLocationResolverInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); $this->locateVisit = new LocateVisit( - $this->ipLocationResolver->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - $this->dbUpdater->reveal(), - $this->eventDispatcher->reveal(), + $this->ipLocationResolver, + $this->em, + $this->logger, + $this->dbUpdater, + $this->eventDispatcher, ); } @@ -59,97 +53,77 @@ class LocateVisitTest extends TestCase public function invalidVisitLogsWarning(): void { $event = new UrlVisited('123'); - $findVisit = $this->em->find(Visit::class, '123')->willReturn(null); - $logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ - 'visitId' => 123, - ]); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); + $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn(null); + $this->em->expects($this->never())->method('flush'); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to locate visit with id "{visitId}", but it does not exist.', + ['visitId' => 123], + ); + $this->eventDispatcher->expects($this->never())->method('dispatch')->with(new VisitLocated('123')); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); ($this->locateVisit)($event); - - $findVisit->shouldHaveBeenCalledOnce(); - $this->em->flush()->shouldNotHaveBeenCalled(); - $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled(); - $logWarning->shouldHaveBeenCalled(); - $dispatch->shouldNotHaveBeenCalled(); } /** @test */ public function nonExistingGeoLiteDbLogsWarning(): void { $event = new UrlVisited('123'); - $findVisit = $this->em->find(Visit::class, '123')->willReturn( + $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), ); - $dbExists = $this->dbUpdater->databaseFileExists()->willReturn(false); - $logWarning = $this->logger->warning( + $this->em->expects($this->never())->method('flush'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(false); + $this->logger->expects($this->once())->method('warning')->with( '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->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); ($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 { $event = new UrlVisited('123'); - $findVisit = $this->em->find(Visit::class, '123')->willReturn( + $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), ); - $resolveLocation = $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow( - WrongIpException::class, - ); - $logWarning = $this->logger->warning( + $this->em->expects($this->never())->method('flush'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); + $this->ipLocationResolver->expects( + $this->once(), + )->method('resolveIpLocation')->withAnyParameters()->willThrowException(WrongIpException::fromIpAddress('')); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', - Argument::type('array'), + $this->isType('array'), ); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); ($this->locateVisit)($event); - - $findVisit->shouldHaveBeenCalledOnce(); - $resolveLocation->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalled(); - $this->em->flush()->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledOnce(); } /** @test */ public function unhandledExceptionLogsError(): void { $event = new UrlVisited('123'); - $findVisit = $this->em->find(Visit::class, '123')->willReturn( + $this->em->expects($this->once())->method('find')->with(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( + $this->em->expects($this->never())->method('flush'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); + $this->ipLocationResolver->expects( + $this->once(), + )->method('resolveIpLocation')->withAnyParameters()->willThrowException(new OutOfRangeException()); + $this->logger->expects($this->once())->method('error')->with( 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', - Argument::type('array'), + $this->isType('array'), ); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); ($this->locateVisit)($event); - - $findVisit->shouldHaveBeenCalledOnce(); - $resolveLocation->shouldHaveBeenCalledOnce(); - $logError->shouldHaveBeenCalled(); - $this->em->flush()->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledOnce(); } /** @@ -159,21 +133,17 @@ class LocateVisitTest extends TestCase public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void { $event = new UrlVisited('123'); - $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush()->will(function (): void { - }); - $resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any()); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); + $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); + $this->em->expects($this->once())->method('flush'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + + $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->logger->expects($this->never())->method('warning'); ($this->locateVisit)($event); self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation(Location::emptyInstance())); - $findVisit->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); - $resolveIp->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledOnce(); } public function provideNonLocatableVisits(): iterable @@ -195,21 +165,19 @@ class LocateVisitTest extends TestCase $location = new Location('', '', '', '', 0.0, 0.0, ''); $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); - $findVisit = $this->em->find(Visit::class, '123')->willReturn($visit); - $flush = $this->em->flush()->will(function (): void { - }); - $resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location); - $dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function (): void { - }); + $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); + $this->em->expects($this->once())->method('flush'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->withAnyParameters()->willReturn(true); + $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->with($ipAddr)->willReturn( + $location, + ); + + $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->logger->expects($this->never())->method('warning'); ($this->locateVisit)($event); self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location)); - $findVisit->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); - $resolveIp->shouldHaveBeenCalledOnce(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $dispatch->shouldHaveBeenCalledOnce(); } public function provideIpAddresses(): iterable diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php index 8e15a3e0..c42bd915 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; use Exception; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; @@ -20,44 +18,40 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; class NotifyNewShortUrlToMercureTest extends TestCase { - use ProphecyTrait; - private NotifyNewShortUrlToMercure $listener; - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); $this->listener = new NotifyNewShortUrlToMercure( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), + $this->helper, + $this->updatesGenerator, + $this->em, + $this->logger, ); } /** @test */ public function messageIsLoggedWhenShortUrlIsNotFound(): void { - $find = $this->em->find(ShortUrl::class, '123')->willReturn(null); - - ($this->listener)(new ShortUrlCreated('123')); - - $find->shouldHaveBeenCalledOnce(); - $this->logger->warning( + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn(null); + $this->helper->expects($this->never())->method('publishUpdate'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlUpdate'); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', ['shortUrlId' => '123', 'name' => 'Mercure'], - )->shouldHaveBeenCalledOnce(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->updatesGenerator->newShortUrlUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + ); + $this->logger->expects($this->never())->method('debug'); + + ($this->listener)(new ShortUrlCreated('123')); } /** @test */ @@ -66,16 +60,15 @@ class NotifyNewShortUrlToMercureTest extends TestCase $shortUrl = ShortUrl::withLongUrl(''); $update = Update::forTopicAndPayload('', []); - $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); - $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl); + $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with($shortUrl)->willReturn( + $update, + ); + $this->helper->expects($this->once())->method('publishUpdate')->with($update); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); ($this->listener)(new ShortUrlCreated('123')); - - $find->shouldHaveBeenCalledOnce(); - $newUpdate->shouldHaveBeenCalledOnce(); - $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ @@ -85,19 +78,20 @@ class NotifyNewShortUrlToMercureTest extends TestCase $update = Update::forTopicAndPayload('', []); $e = new Exception('Error'); - $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); - $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); - $publish = $this->helper->publishUpdate($update)->willThrow($e); - - ($this->listener)(new ShortUrlCreated('123')); - - $find->shouldHaveBeenCalledOnce(); - $newUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug( + $this->em->expects($this->once())->method('find')->with( + ShortUrl::class, + '123', + )->willReturn($shortUrl); + $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with($shortUrl)->willReturn( + $update, + ); + $this->helper->expects($this->once())->method('publishUpdate')->with($update)->willThrowException($e); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->once())->method('debug')->with( 'Error while trying to notify {name} with new short URL. {e}', ['e' => $e, 'name' => 'Mercure'], - )->shouldHaveBeenCalledOnce(); + ); + + ($this->listener)(new ShortUrlCreated('123')); } } diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 726e272c..1cecada7 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; @@ -23,55 +21,38 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; class NotifyVisitToMercureTest extends TestCase { - use ProphecyTrait; - private NotifyVisitToMercure $listener; - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); - $this->listener = new NotifyVisitToMercure( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - ); + $this->listener = new NotifyVisitToMercure($this->helper, $this->updatesGenerator, $this->em, $this->logger); } /** @test */ public function notificationsAreNotSentWhenVisitCannotBeFound(): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); - $logWarning = $this->logger->warning( + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn(null); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', ['visitId' => $visitId, 'name' => 'Mercure'], ); - $logDebug = $this->logger->debug(Argument::cetera()); - $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate( - Argument::type(Visit::class), - ); - $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class)); - $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class)); - $publish = $this->helper->publishUpdate(Argument::type(Update::class)); + $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlVisitUpdate'); + $this->updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); + $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); + $this->helper->expects($this->never())->method('publishUpdate'); ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $logDebug->shouldNotHaveBeenCalled(); - $buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled(); - $buildNewVisitUpdate->shouldNotHaveBeenCalled(); - $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); - $publish->shouldNotHaveBeenCalled(); } /** @test */ @@ -81,23 +62,17 @@ class NotifyVisitToMercureTest extends TestCase $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); $update = Update::forTopicAndPayload('', []); - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $logWarning = $this->logger->warning(Argument::cetera()); - $logDebug = $this->logger->debug(Argument::cetera()); - $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); - $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); - $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->helper->publishUpdate($update); + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->once())->method('newShortUrlVisitUpdate')->with($visit)->willReturn( + $update, + ); + $this->updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); + $this->updatesGenerator->expects($this->once())->method('newVisitUpdate')->with($visit)->willReturn($update); + $this->helper->expects($this->exactly(2))->method('publishUpdate')->with($update); ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldNotHaveBeenCalled(); - $logDebug->shouldNotHaveBeenCalled(); - $buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce(); - $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); - $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); - $publish->shouldHaveBeenCalledTimes(2); } /** @test */ @@ -108,26 +83,20 @@ class NotifyVisitToMercureTest extends TestCase $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $logWarning = $this->logger->warning(Argument::cetera()); - $logDebug = $this->logger->debug('Error while trying to notify {name} with new visit. {e}', [ - 'e' => $e, - 'name' => 'Mercure', - ]); - $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); - $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); - $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->helper->publishUpdate($update)->willThrow($e); + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->once())->method('debug')->with( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => 'Mercure'], + ); + $this->updatesGenerator->expects($this->once())->method('newShortUrlVisitUpdate')->with($visit)->willReturn( + $update, + ); + $this->updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); + $this->updatesGenerator->expects($this->once())->method('newVisitUpdate')->with($visit)->willReturn($update); + $this->helper->expects($this->once())->method('publishUpdate')->with($update)->willThrowException($e); ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldNotHaveBeenCalled(); - $logDebug->shouldHaveBeenCalledOnce(); - $buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce(); - $buildNewVisitUpdate->shouldHaveBeenCalledOnce(); - $buildNewOrphanVisitUpdate->shouldNotHaveBeenCalled(); - $publish->shouldHaveBeenCalledOnce(); } /** @@ -139,23 +108,17 @@ class NotifyVisitToMercureTest extends TestCase $visitId = '123'; $update = Update::forTopicAndPayload('', []); - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $logWarning = $this->logger->warning(Argument::cetera()); - $logDebug = $this->logger->debug(Argument::cetera()); - $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); - $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); - $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->helper->publishUpdate($update); + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); + $this->updatesGenerator->expects($this->never())->method('newShortUrlVisitUpdate'); + $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with($visit)->willReturn( + $update, + ); + $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); + $this->helper->expects($this->once())->method('publishUpdate')->with($update); ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldNotHaveBeenCalled(); - $logDebug->shouldNotHaveBeenCalled(); - $buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled(); - $buildNewVisitUpdate->shouldNotHaveBeenCalled(); - $buildNewOrphanVisitUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); } public function provideOrphanVisits(): iterable diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 2751dbfd..7a5cb888 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -12,10 +12,8 @@ use GuzzleHttp\Promise\FulfilledPromise; use GuzzleHttp\Promise\RejectedPromise; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; @@ -32,66 +30,48 @@ use function Functional\contains; class NotifyVisitToWebHooksTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $httpClient; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & ClientInterface $httpClient; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->httpClient = $this->prophesize(ClientInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->httpClient = $this->createMock(ClientInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @test */ public function emptyWebhooksMakeNoFurtherActions(): void { - $find = $this->em->find(Visit::class, '1')->willReturn(null); + $this->em->expects($this->never())->method('find'); $this->createListener([])(new VisitLocated('1')); - - $find->shouldNotHaveBeenCalled(); } /** @test */ public function invalidVisitDoesNotPerformAnyRequest(): void { - $find = $this->em->find(Visit::class, '1')->willReturn(null); - $requestAsync = $this->httpClient->requestAsync( - RequestMethodInterface::METHOD_POST, - Argument::type('string'), - Argument::type('array'), - )->willReturn(new FulfilledPromise('')); - $logWarning = $this->logger->warning( + $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn(null); + $this->httpClient->expects($this->never())->method('requestAsync'); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', ['visitId' => '1'], ); $this->createListener(['foo', 'bar'])(new VisitLocated('1')); - - $find->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $requestAsync->shouldNotHaveBeenCalled(); } /** @test */ public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void { - $find = $this->em->find(Visit::class, '1')->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $requestAsync = $this->httpClient->requestAsync( - RequestMethodInterface::METHOD_POST, - Argument::type('string'), - Argument::type('array'), - )->willReturn(new FulfilledPromise('')); - $logWarning = $this->logger->warning(Argument::cetera()); + $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn( + Visit::forBasePath(Visitor::emptyInstance()), + ); + $this->httpClient->expects($this->never())->method('requestAsync'); + $this->logger->expects($this->never())->method('warning'); $this->createListener(['foo', 'bar'], false)(new VisitLocated('1')); - - $find->shouldHaveBeenCalledOnce(); - $logWarning->shouldNotHaveBeenCalled(); - $requestAsync->shouldNotHaveBeenCalled(); } /** @@ -103,16 +83,16 @@ class NotifyVisitToWebHooksTest extends TestCase $webhooks = ['foo', 'invalid', 'bar', 'baz']; $invalidWebhooks = ['invalid', 'baz']; - $find = $this->em->find(Visit::class, '1')->willReturn($visit); - $requestAsync = $this->httpClient->requestAsync( + $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn($visit); + $this->httpClient->expects($this->exactly(count($webhooks)))->method('requestAsync')->with( RequestMethodInterface::METHOD_POST, - Argument::type('string'), - Argument::that(function (array $requestOptions) use ($expectedResponseKeys) { + $this->istype('string'), + $this->callback(function (array $requestOptions) use ($expectedResponseKeys) { Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions); Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions); Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions); - Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10); - Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']); + Assert::assertEquals(10, $requestOptions[RequestOptions::TIMEOUT]); + Assert::assertEquals(['User-Agent' => 'Shlink:v1.2.3'], $requestOptions[RequestOptions::HEADERS]); $json = $requestOptions[RequestOptions::JSON]; Assert::assertCount(count($expectedResponseKeys), $json); @@ -120,30 +100,24 @@ class NotifyVisitToWebHooksTest extends TestCase Assert::assertArrayHasKey($key, $json); } - return $requestOptions; + return true; }), - )->will(function (array $args) use ($invalidWebhooks) { - [, $webhook] = $args; + )->willReturnCallback(function ($_, $webhook) use ($invalidWebhooks) { $shouldReject = contains($invalidWebhooks, $webhook); - return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise(''); }); - $logWarning = $this->logger->warning( + $this->logger->expects($this->exactly(count($invalidWebhooks)))->method('warning')->with( 'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', - Argument::that(function (array $extra) { + $this->callback(function (array $extra): bool { Assert::assertArrayHasKey('webhook', $extra); Assert::assertArrayHasKey('visitId', $extra); Assert::assertArrayHasKey('e', $extra); - return $extra; + return true; }), ); $this->createListener($webhooks)(new VisitLocated('1')); - - $find->shouldHaveBeenCalledOnce(); - $requestAsync->shouldHaveBeenCalledTimes(count($webhooks)); - $logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks)); } public function provideVisits(): iterable @@ -158,9 +132,9 @@ class NotifyVisitToWebHooksTest extends TestCase private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks { return new NotifyVisitToWebHooks( - $this->httpClient->reveal(), - $this->em->reveal(), - $this->logger->reveal(), + $this->httpClient, + $this->em, + $this->logger, new WebhookOptions( ['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits], ), diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 99ebb820..c7a4ecd0 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -35,7 +35,7 @@ class PublishingUpdatesGeneratorTest extends TestCase */ public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', 'longUrl' => '', 'title' => $title, @@ -63,6 +63,11 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $title, 'crawlable' => false, 'forwardQuery' => true, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], 'visit' => [ 'referer' => '', @@ -114,7 +119,7 @@ class PublishingUpdatesGeneratorTest extends TestCase /** @test */ public function shortUrlIsProperlySerializedIntoUpdate(): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', 'longUrl' => '', 'title' => 'The title', @@ -139,6 +144,11 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $shortUrl->title(), 'crawlable' => false, 'forwardQuery' => true, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ]], $update->payload); } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php index 477654bb..764f7949 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; @@ -25,48 +23,43 @@ use Throwable; class NotifyNewShortUrlToRabbitMqTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - ($this->listener(false))(new ShortUrlCreated('123')); + $this->helper->expects($this->never())->method('publishUpdate'); + $this->em->expects($this->never())->method('find'); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + ($this->listener(false))(new ShortUrlCreated('123')); } /** @test */ public function notificationsAreNotSentWhenShortUrlCannotBeFound(): void { $shortUrlId = '123'; - $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(null); - $logWarning = $this->logger->warning( + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn(null); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', ['shortUrlId' => $shortUrlId, 'name' => 'RabbitMQ'], ); + $this->logger->expects($this->never())->method('debug'); + $this->helper->expects($this->never())->method('publishUpdate'); ($this->listener())(new ShortUrlCreated($shortUrlId)); - - $find->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ @@ -74,17 +67,16 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase { $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); - $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); - $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( - $update, + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( + ShortUrl::withLongUrl(''), ); + $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( + $this->isInstanceOf(ShortUrl::class), + )->willReturn($update); + $this->helper->expects($this->once())->method('publishUpdate')->with($update); + $this->logger->expects($this->never())->method('debug'); ($this->listener())(new ShortUrlCreated($shortUrlId)); - - $find->shouldHaveBeenCalledOnce(); - $generateUpdate->shouldHaveBeenCalledOnce(); - $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @@ -95,21 +87,19 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase { $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); - $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); - $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( - $update, + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( + ShortUrl::withLongUrl(''), ); - $publish = $this->helper->publishUpdate($update)->willThrow($e); - - ($this->listener())(new ShortUrlCreated($shortUrlId)); - - $this->logger->debug( + $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( + $this->isInstanceOf(ShortUrl::class), + )->willReturn($update); + $this->helper->expects($this->once())->method('publishUpdate')->with($update)->willThrowException($e); + $this->logger->expects($this->once())->method('debug')->with( 'Error while trying to notify {name} with new short URL. {e}', ['e' => $e, 'name' => 'RabbitMQ'], - )->shouldHaveBeenCalledOnce(); - $find->shouldHaveBeenCalledOnce(); - $generateUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); + ); + + ($this->listener())(new ShortUrlCreated($shortUrlId)); } public function provideExceptions(): iterable @@ -122,10 +112,10 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase private function listener(bool $enabled = true): NotifyNewShortUrlToRabbitMq { return new NotifyNewShortUrlToRabbitMq( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), + $this->helper, + $this->updatesGenerator, + $this->em, + $this->logger, new RabbitMqOptions($enabled), ); } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index aef04cdf..8b7b392c 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -8,10 +8,8 @@ use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; @@ -33,48 +31,43 @@ use function Functional\noop; class NotifyVisitToRabbitMqTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - ($this->listener(new RabbitMqOptions(enabled: false)))(new VisitLocated('123')); + $this->helper->expects($this->never())->method('publishUpdate'); + $this->em->expects($this->never())->method('find'); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + ($this->listener(new RabbitMqOptions(enabled: false)))(new VisitLocated('123')); } /** @test */ public function notificationsAreNotSentWhenVisitCannotBeFound(): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); - $logWarning = $this->logger->warning( + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn(null); + $this->logger->expects($this->once())->method('warning')->with( 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', ['visitId' => $visitId, 'name' => 'RabbitMQ'], ); + $this->logger->expects($this->never())->method('debug'); + $this->helper->expects($this->never())->method('publishUpdate'); ($this->listener())(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @@ -84,20 +77,18 @@ class NotifyVisitToRabbitMqTest extends TestCase public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); each($expectedChannels, function (string $method): void { - $this->updatesGenerator->{$method}(Argument::type(Visit::class))->willReturn( - Update::forTopicAndPayload('', []), - )->shouldBeCalledOnce(); + $this->updatesGenerator->expects($this->once())->method($method)->with( + $this->isInstanceOf(Visit::class), + )->willReturn(Update::forTopicAndPayload('', [])); }); + $this->helper->expects($this->exactly(count($expectedChannels)))->method('publishUpdate')->with( + $this->isInstanceOf(Update::class), + ); + $this->logger->expects($this->never())->method('debug'); ($this->listener())(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $this->helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideVisits(): iterable @@ -107,7 +98,7 @@ class NotifyVisitToRabbitMqTest extends TestCase yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']]; yield 'non-orphan visit' => [ Visit::forValidShortUrl( - ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'foo', 'customSlug' => 'bar', ])), @@ -124,21 +115,19 @@ class NotifyVisitToRabbitMqTest extends TestCase public function printsDebugMessageInCaseOfError(Throwable $e): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( - Update::forTopicAndPayload('', []), + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + Visit::forBasePath(Visitor::emptyInstance()), ); - $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); - - ($this->listener())(new VisitLocated($visitId)); - - $this->logger->debug( + $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( + $this->isInstanceOf(Visit::class), + )->willReturn(Update::forTopicAndPayload('', [])); + $this->helper->expects($this->once())->method('publishUpdate')->withAnyParameters()->willThrowException($e); + $this->logger->expects($this->once())->method('debug')->with( 'Error while trying to notify {name} with new visit. {e}', ['e' => $e, 'name' => 'RabbitMQ'], - )->shouldHaveBeenCalledOnce(); - $findVisit->shouldHaveBeenCalledOnce(); - $generateUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); + ); + + ($this->listener())(new VisitLocated($visitId)); } public function provideExceptions(): iterable @@ -155,17 +144,15 @@ class NotifyVisitToRabbitMqTest extends TestCase public function expectedPayloadIsPublishedDependingOnConfig( bool $legacy, Visit $visit, - callable $assert, callable $setup, + callable $expect, ): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); $setup($this->updatesGenerator); + $expect($this->helper, $this->updatesGenerator); ($this->listener(new RabbitMqOptions(true, $legacy)))(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $assert($this->helper, $this->updatesGenerator); } public function provideLegacyPayloads(): iterable @@ -173,8 +160,9 @@ class NotifyVisitToRabbitMqTest extends TestCase yield 'legacy non-orphan visit' => [ true, $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), - function (ObjectProphecy|PublishingHelperInterface $helper) use ($visit): void { - $helper->publishUpdate(Argument::that(function (Update $update) use ($visit): bool { + noop(...), + function (MockObject & PublishingHelperInterface $helper) use ($visit): void { + $helper->method('publishUpdate')->with($this->callback(function (Update $update) use ($visit): bool { $payload = $update->payload; Assert::assertEquals($payload, $visit->jsonSerialize()); Assert::assertArrayNotHasKey('visitedUrl', $payload); @@ -185,13 +173,13 @@ class NotifyVisitToRabbitMqTest extends TestCase return true; })); }, - noop(...), ]; yield 'legacy orphan visit' => [ true, Visit::forBasePath(Visitor::emptyInstance()), - function (ObjectProphecy|PublishingHelperInterface $helper): void { - $helper->publishUpdate(Argument::that(function (Update $update): bool { + noop(...), + function (MockObject & PublishingHelperInterface $helper): void { + $helper->method('publishUpdate')->with($this->callback(function (Update $update): bool { $payload = $update->payload; Assert::assertArrayHasKey('visitedUrl', $payload); Assert::assertArrayHasKey('type', $payload); @@ -199,35 +187,33 @@ class NotifyVisitToRabbitMqTest extends TestCase return true; })); }, - noop(...), ]; yield 'non-legacy non-orphan visit' => [ false, Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), - function (ObjectProphecy|PublishingHelperInterface $helper): void { - $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes(2); - }, - function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); - $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->shouldNotBeCalled(); - $updatesGenerator->newVisitUpdate(Argument::cetera())->willReturn($update) - ->shouldBeCalledOnce(); - $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->willReturn($update) - ->shouldBeCalledOnce(); + $updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); + $updatesGenerator->expects($this->once())->method('newVisitUpdate')->withAnyParameters()->willReturn( + $update, + ); + $updatesGenerator->expects($this->once())->method('newShortUrlVisitUpdate')->willReturn($update); + }, + function (MockObject & PublishingHelperInterface $helper): void { + $helper->expects($this->exactly(2))->method('publishUpdate')->with($this->isInstanceOf(Update::class)); }, ]; yield 'non-legacy orphan visit' => [ false, Visit::forBasePath(Visitor::emptyInstance()), - function (ObjectProphecy|PublishingHelperInterface $helper): void { - $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledOnce(); - }, - function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); - $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->willReturn($update) - ->shouldBeCalledOnce(); - $updatesGenerator->newVisitUpdate(Argument::cetera())->shouldNotBeCalled(); - $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + $updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->willReturn($update); + $updatesGenerator->expects($this->never())->method('newVisitUpdate'); + $updatesGenerator->expects($this->never())->method('newShortUrlVisitUpdate'); + }, + function (MockObject & PublishingHelperInterface $helper): void { + $helper->expects($this->once())->method('publishUpdate')->with($this->isInstanceOf(Update::class)); }, ]; } @@ -235,10 +221,10 @@ class NotifyVisitToRabbitMqTest extends TestCase private function listener(?RabbitMqOptions $options = null): NotifyVisitToRabbitMq { return new NotifyVisitToRabbitMq( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), + $this->helper, + $this->updatesGenerator, + $this->em, + $this->logger, new OrphanVisitDataTransformer(), $options ?? new RabbitMqOptions(enabled: true, legacyVisitsPublishing: false), ); diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php index ec3338d1..0b5dfd27 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RedisPubSub; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; @@ -24,30 +22,28 @@ use Throwable; class NotifyNewShortUrlToRedisTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - $this->createListener(false)(new ShortUrlCreated('123')); + $this->helper->expects($this->never())->method('publishUpdate'); + $this->em->expects($this->never())->method('find'); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->createListener(false)(new ShortUrlCreated('123')); } /** @@ -58,21 +54,19 @@ class NotifyNewShortUrlToRedisTest extends TestCase { $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); - $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); - $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( - $update, + $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( + ShortUrl::withLongUrl(''), ); - $publish = $this->helper->publishUpdate($update)->willThrow($e); - - $this->createListener()(new ShortUrlCreated($shortUrlId)); - - $this->logger->debug( + $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( + $this->isInstanceOf(ShortUrl::class), + )->willReturn($update); + $this->helper->expects($this->once())->method('publishUpdate')->with($update)->willThrowException($e); + $this->logger->expects($this->once())->method('debug')->with( 'Error while trying to notify {name} with new short URL. {e}', ['e' => $e, 'name' => 'Redis pub/sub'], - )->shouldHaveBeenCalledOnce(); - $find->shouldHaveBeenCalledOnce(); - $generateUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); + ); + + $this->createListener()(new ShortUrlCreated($shortUrlId)); } public function provideExceptions(): iterable @@ -84,12 +78,6 @@ class NotifyNewShortUrlToRedisTest extends TestCase private function createListener(bool $enabled = true): NotifyNewShortUrlToRedis { - return new NotifyNewShortUrlToRedis( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - $enabled, - ); + return new NotifyNewShortUrlToRedis($this->helper, $this->updatesGenerator, $this->em, $this->logger, $enabled); } } diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php index 5c1e797b..f50cf906 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RedisPubSub; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; @@ -24,30 +22,28 @@ use Throwable; class NotifyVisitToRedisTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $helper; - private ObjectProphecy $updatesGenerator; - private ObjectProphecy $em; - private ObjectProphecy $logger; + private MockObject & PublishingHelperInterface $helper; + private MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator; + private MockObject & EntityManagerInterface $em; + private MockObject & LoggerInterface $logger; protected function setUp(): void { - $this->helper = $this->prophesize(PublishingHelperInterface::class); - $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); + $this->helper = $this->createMock(PublishingHelperInterface::class); + $this->updatesGenerator = $this->createMock(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); } /** @test */ public function doesNothingWhenTheFeatureIsNotEnabled(): void { - $this->createListener(false)(new VisitLocated('123')); + $this->helper->expects($this->never())->method('publishUpdate'); + $this->em->expects($this->never())->method('find'); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('debug'); - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->createListener(false)(new VisitLocated('123')); } /** @@ -57,21 +53,19 @@ class NotifyVisitToRedisTest extends TestCase public function printsDebugMessageInCaseOfError(Throwable $e): void { $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( - Update::forTopicAndPayload('', []), + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + Visit::forBasePath(Visitor::emptyInstance()), ); - $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); - - $this->createListener()(new VisitLocated($visitId)); - - $this->logger->debug( + $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( + $this->isInstanceOf(Visit::class), + )->willReturn(Update::forTopicAndPayload('', [])); + $this->helper->expects($this->once())->method('publishUpdate')->withAnyParameters()->willThrowException($e); + $this->logger->expects($this->once())->method('debug')->with( 'Error while trying to notify {name} with new visit. {e}', ['e' => $e, 'name' => 'Redis pub/sub'], - )->shouldHaveBeenCalledOnce(); - $findVisit->shouldHaveBeenCalledOnce(); - $generateUpdate->shouldHaveBeenCalledOnce(); - $publish->shouldHaveBeenCalledOnce(); + ); + + $this->createListener()(new VisitLocated($visitId)); } public function provideExceptions(): iterable @@ -83,12 +77,6 @@ class NotifyVisitToRedisTest extends TestCase private function createListener(bool $enabled = true): NotifyVisitToRedis { - return new NotifyVisitToRedis( - $this->helper->reveal(), - $this->updatesGenerator->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - $enabled, - ); + return new NotifyVisitToRedis($this->helper, $this->updatesGenerator, $this->em, $this->logger, $enabled); } } diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index 9ce20801..5b496123 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\EventDispatcher; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use RuntimeException; @@ -20,24 +18,18 @@ use function Functional\map; class UpdateGeoLiteDbTest extends TestCase { - use ProphecyTrait; - private UpdateGeoLiteDb $listener; - private ObjectProphecy $dbUpdater; - private ObjectProphecy $logger; - private ObjectProphecy $eventDispatcher; + private MockObject & GeolocationDbUpdaterInterface $dbUpdater; + private MockObject & LoggerInterface $logger; + private MockObject & EventDispatcherInterface $eventDispatcher; protected function setUp(): void { - $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); - $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->dbUpdater = $this->createMock(GeolocationDbUpdaterInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $this->listener = new UpdateGeoLiteDb( - $this->dbUpdater->reveal(), - $this->logger->reveal(), - $this->eventDispatcher->reveal(), - ); + $this->listener = new UpdateGeoLiteDb($this->dbUpdater, $this->logger, $this->eventDispatcher); } /** @test */ @@ -45,15 +37,15 @@ class UpdateGeoLiteDbTest extends TestCase { $e = new RuntimeException(); - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e); - $logError = $this->logger->error('GeoLite2 database download failed. {e}', ['e' => $e]); + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willThrowException($e); + $this->logger->expects($this->once())->method('error')->with( + 'GeoLite2 database download failed. {e}', + ['e' => $e], + ); + $this->logger->expects($this->never())->method('notice'); + $this->eventDispatcher->expects($this->never())->method('dispatch'); ($this->listener)(); - - $checkDbUpdate->shouldHaveBeenCalledOnce(); - $logError->shouldHaveBeenCalledOnce(); - $this->logger->notice(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @@ -62,22 +54,17 @@ class UpdateGeoLiteDbTest extends TestCase */ public function noticeMessageIsPrintedWhenFirstCallbackIsInvoked(bool $oldDbExists, string $expectedMessage): void { - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($oldDbExists): GeolocationResult { - [$firstCallback] = $args; + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( + function (callable $firstCallback) use ($oldDbExists): GeolocationResult { $firstCallback($oldDbExists); - return GeolocationResult::DB_IS_UP_TO_DATE; }, ); - $logNotice = $this->logger->notice($expectedMessage); + $this->logger->expects($this->once())->method('notice')->with($expectedMessage); + $this->logger->expects($this->never())->method('error'); + $this->eventDispatcher->expects($this->never())->method('dispatch'); ($this->listener)(); - - $checkDbUpdate->shouldHaveBeenCalledOnce(); - $logNotice->shouldHaveBeenCalledOnce(); - $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideFlags(): iterable @@ -96,10 +83,8 @@ class UpdateGeoLiteDbTest extends TestCase bool $oldDbExists, ?string $expectedMessage, ): void { - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->will( - function (array $args) use ($total, $downloaded, $oldDbExists): GeolocationResult { - [, $secondCallback] = $args; - + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( + function ($_, callable $secondCallback) use ($total, $downloaded, $oldDbExists): GeolocationResult { // Invoke several times to ensure the log is printed only once $secondCallback($total, $downloaded, $oldDbExists); $secondCallback($total, $downloaded, $oldDbExists); @@ -108,18 +93,12 @@ class UpdateGeoLiteDbTest extends TestCase return GeolocationResult::DB_UPDATED; }, ); - $logNotice = $this->logger->notice($expectedMessage ?? Argument::cetera()); + $logNoticeExpectation = $expectedMessage !== null ? $this->once() : $this->never(); + $this->logger->expects($logNoticeExpectation)->method('notice')->with($expectedMessage); + $this->logger->expects($this->never())->method('error'); + $this->eventDispatcher->expects($this->never())->method('dispatch'); ($this->listener)(); - - if ($expectedMessage !== null) { - $logNotice->shouldHaveBeenCalledOnce(); - } else { - $logNotice->shouldNotHaveBeenCalled(); - } - $checkDbUpdate->shouldHaveBeenCalledOnce(); - $this->logger->error(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideDownloaded(): iterable @@ -142,12 +121,12 @@ class UpdateGeoLiteDbTest extends TestCase GeolocationResult $result, int $expectedDispatches, ): void { - $checkDbUpdate = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willReturn($result); + $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn($result); + $this->eventDispatcher->expects($this->exactly($expectedDispatches))->method('dispatch')->with( + new GeoLiteDbCreated(), + ); ($this->listener)(); - - $checkDbUpdate->shouldHaveBeenCalledOnce(); - $this->eventDispatcher->dispatch(new GeoLiteDbCreated())->shouldHaveBeenCalledTimes($expectedDispatches); } public function provideGeolocationResults(): iterable diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index a0980738..b34badf3 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -8,7 +8,6 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\InputFilter\InputFilterInterface; use LogicException; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use RuntimeException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Throwable; @@ -18,8 +17,6 @@ use function print_r; class ValidationExceptionTest extends TestCase { - use ProphecyTrait; - /** * @test * @dataProvider provideExceptions @@ -36,10 +33,10 @@ class ValidationExceptionTest extends TestCase 'something' => {$barValue} EOT; - $inputFilter = $this->prophesize(InputFilterInterface::class); - $getMessages = $inputFilter->getMessages()->willReturn($invalidData); + $inputFilter = $this->createMock(InputFilterInterface::class); + $inputFilter->expects($this->once())->method('getMessages')->with()->willReturn($invalidData); - $e = ValidationException::fromInputFilter($inputFilter->reveal(), $prev); + $e = ValidationException::fromInputFilter($inputFilter, $prev); self::assertEquals($invalidData, $e->getInvalidElements()); self::assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData()); @@ -47,7 +44,6 @@ class ValidationExceptionTest extends TestCase self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); self::assertEquals($prev, $e->getPrevious()); self::assertStringContainsString($expectedStringRepresentation, (string) $e); - $getMessages->shouldHaveBeenCalledOnce(); } public function provideExceptions(): iterable diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 2ce93647..c480e11a 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\Importer; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -19,45 +17,48 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; +use Shlinkio\Shlink\Importer\Model\ImportResult; use Shlinkio\Shlink\Importer\Params\ImportParams; use Shlinkio\Shlink\Importer\Sources\ImportSource; +use stdClass; use Symfony\Component\Console\Style\StyleInterface; use function count; use function Functional\contains; use function Functional\some; +use function sprintf; use function str_contains; class ImportedLinksProcessorTest extends TestCase { - use ProphecyTrait; - private ImportedLinksProcessor $processor; - private ObjectProphecy $em; - private ObjectProphecy $shortCodeHelper; - private ObjectProphecy $repo; - private ObjectProphecy $io; + private MockObject & EntityManagerInterface $em; + private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; + private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & StyleInterface $io; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $this->em->getRepository(ShortUrl::class)->willReturn($this->repo->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); - $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class); - $batchHelper = $this->prophesize(DoctrineBatchHelperInterface::class); - $batchHelper->wrapIterable(Argument::cetera())->willReturnArgument(0); + $this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class); + $batchHelper = $this->createMock(DoctrineBatchHelperInterface::class); + $batchHelper->method('wrapIterable')->willReturnArgument(0); $this->processor = new ImportedLinksProcessor( - $this->em->reveal(), + $this->em, new SimpleShortUrlRelationResolver(), - $this->shortCodeHelper->reveal(), - $batchHelper->reveal(), + $this->shortCodeHelper, + $batchHelper, ); - $this->io = $this->prophesize(StyleInterface::class); + $this->io = $this->createMock(StyleInterface::class); } /** @test */ @@ -70,16 +71,17 @@ class ImportedLinksProcessorTest extends TestCase ]; $expectedCalls = count($urls); - $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->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); + $this->repo->expects($this->exactly($expectedCalls))->method('findOneByImportedUrl')->willReturn(null); + $this->shortCodeHelper->expects($this->exactly($expectedCalls)) + ->method('ensureShortCodeUniqueness') + ->willReturn(true); + $this->em->expects($this->exactly($expectedCalls))->method('persist')->with( + $this->isInstanceOf(ShortUrl::class), + ); + $this->io->expects($this->exactly($expectedCalls))->method('text')->with($this->isType('string')); - $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); - - $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls); - $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); - $persist->shouldHaveBeenCalledTimes($expectedCalls); - $this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls); + $this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams()); } /** @test */ @@ -91,26 +93,22 @@ class ImportedLinksProcessorTest extends TestCase new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', null), ]; - $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); - $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); - $persist = $this->em->persist(Argument::type(ShortUrl::class))->will(function (array $args): void { - /** @var ShortUrl $shortUrl */ - [$shortUrl] = $args; - + $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); + $this->repo->expects($this->exactly(3))->method('findOneByImportedUrl')->willReturn(null); + $this->shortCodeHelper->expects($this->exactly(3))->method('ensureShortCodeUniqueness')->willReturn(true); + $this->em->expects($this->exactly(3))->method('persist')->with( + $this->isInstanceOf(ShortUrl::class), + )->willReturnCallback(function (ShortUrl $shortUrl): void { if ($shortUrl->getShortCode() === 'baz') { throw new RuntimeException('Whatever error'); } }); + $textCalls = $this->setUpIoText('Skipped. Reason: Whatever error', 'Imported'); - $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); + $this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams()); - $importedUrlExists->shouldHaveBeenCalledTimes(3); - $ensureUniqueness->shouldHaveBeenCalledTimes(3); - $persist->shouldHaveBeenCalledTimes(3); - $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); - $this->io->text( - Argument::containingString('Skipped. Reason: Whatever error'), - )->shouldHaveBeenCalledOnce(); + self::assertEquals(2, $textCalls->importedCount); + self::assertEquals(1, $textCalls->skippedCount); } /** @test */ @@ -124,24 +122,19 @@ class ImportedLinksProcessorTest extends TestCase new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', null), ]; - $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->will( - function (array $args): ?ShortUrl { - /** @var ImportedShlinkUrl $url */ - [$url] = $args; - - return contains(['foo', 'baz2', 'baz3'], $url->longUrl) ? ShortUrl::fromImport($url, true) : null; - }, + $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); + $this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturnCallback( + fn (ImportedShlinkUrl $url): ?ShortUrl + => 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->shortCodeHelper->expects($this->exactly(2))->method('ensureShortCodeUniqueness')->willReturn(true); + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ShortUrl::class)); + $textCalls = $this->setUpIoText(); - $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); + $this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams()); - $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); - $ensureUniqueness->shouldHaveBeenCalledTimes(2); - $persist->shouldHaveBeenCalledTimes(2); - $this->io->text(Argument::containingString('Skipped'))->shouldHaveBeenCalledTimes(3); - $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); + self::assertEquals(2, $textCalls->importedCount); + self::assertEquals(3, $textCalls->skippedCount); } /** @test */ @@ -155,32 +148,21 @@ class ImportedLinksProcessorTest extends TestCase new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', 'bar'), ]; - $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); - $failingEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness( - Argument::any(), - true, - )->willReturn(false); - $successEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness( - Argument::any(), - false, - )->willReturn(true); - $choice = $this->io->choice(Argument::cetera())->will(function (array $args) { - /** @var ImportedShlinkUrl $url */ - [$question] = $args; - + $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); + $this->repo->expects($this->exactly(count($urls)))->method('findOneByImportedUrl')->willReturn(null); + $this->shortCodeHelper->expects($this->exactly(7))->method('ensureShortCodeUniqueness')->willReturnCallback( + fn ($_, bool $hasCustomSlug) => ! $hasCustomSlug, + ); + $this->em->expects($this->exactly(2))->method('persist')->with($this->isInstanceOf(ShortUrl::class)); + $this->io->expects($this->exactly(5))->method('choice')->willReturnCallback(function (string $question) { return some(['foo', 'baz2', 'baz3'], fn (string $item) => str_contains($question, $item)) ? 'Skip' : ''; }); - $persist = $this->em->persist(Argument::type(ShortUrl::class)); + $textCalls = $this->setUpIoText('Error'); - $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); + $this->processor->process($this->io, ImportResult::withShortUrls($urls), $this->buildParams()); - $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); - $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); - $successEnsureUniqueness->shouldHaveBeenCalledTimes(2); - $choice->shouldHaveBeenCalledTimes(5); - $persist->shouldHaveBeenCalledTimes(2); - $this->io->text(Argument::containingString('Error'))->shouldHaveBeenCalledTimes(3); - $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); + self::assertEquals(2, $textCalls->importedCount); + self::assertEquals(3, $textCalls->skippedCount); } /** @@ -193,18 +175,17 @@ class ImportedLinksProcessorTest extends TestCase 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->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); + $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($foundShortUrl); + $this->shortCodeHelper->expects($this->exactly($foundShortUrl === null ? 1 : 0)) + ->method('ensureShortCodeUniqueness') + ->willReturn(true); + $this->em->expects($this->exactly($amountOfPersistedVisits + ($foundShortUrl === null ? 1 : 0)))->method( + 'persist', + )->with($this->callback(fn (object $arg) => $arg instanceof ShortUrl || $arg instanceof Visit)); + $this->io->expects($this->once())->method('text')->with($this->stringContains($expectedOutput)); - $this->processor->process($this->io->reveal(), [$importedUrl], $this->buildParams()); - - $findExisting->shouldHaveBeenCalledOnce(); - $ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); - $persistUrl->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); - $persistVisits->shouldHaveBeenCalledTimes($amountOfPersistedVisits); - $this->io->text(Argument::containingString($expectedOutput))->shouldHaveBeenCalledOnce(); + $this->processor->process($this->io, ImportResult::withShortUrls([$importedUrl]), $this->buildParams()); } public function provideUrlsWithVisits(): iterable @@ -247,8 +228,87 @@ class ImportedLinksProcessorTest extends TestCase ]; } - private function buildParams(): ImportParams + /** + * @param iterable $visits + * @test + * @dataProvider provideOrphanVisits + */ + public function properAmountOfOrphanVisitsIsImported( + bool $importOrphanVisits, + iterable $visits, + ?Visit $lastOrphanVisit, + int $expectedImportedVisits, + ): void { + $this->io->expects($this->exactly($importOrphanVisits ? 2 : 1))->method('title'); + $this->io->expects($importOrphanVisits ? $this->once() : $this->never())->method('text')->with( + sprintf('Imported %s orphan visits.', $expectedImportedVisits), + ); + + $visitRepo = $this->createMock(VisitRepositoryInterface::class); + $visitRepo->expects($importOrphanVisits ? $this->once() : $this->never())->method( + 'findMostRecentOrphanVisit', + )->willReturn($lastOrphanVisit); + $this->em->expects($importOrphanVisits ? $this->once() : $this->never())->method('getRepository')->with( + Visit::class, + )->willReturn($visitRepo); + $this->em->expects($importOrphanVisits ? $this->exactly($expectedImportedVisits) : $this->never())->method( + 'persist', + )->with($this->isInstanceOf(Visit::class)); + + $this->processor->process( + $this->io, + ImportResult::withShortUrlsAndOrphanVisits([], $visits), + $this->buildParams($importOrphanVisits), + ); + } + + public function provideOrphanVisits(): iterable { - return ImportSource::BITLY->toParamsWithCallableMap(['import_short_codes' => static fn () => true]); + yield 'import orphan disable without visits' => [false, [], null, 0]; + yield 'import orphan enabled without visits' => [true, [], null, 0]; + yield 'import orphan disabled with visits' => [false, [ + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + ], null, 0]; + yield 'import orphan enabled with visits' => [true, [ + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now(), '', '', null), + ], null, 5]; + yield 'existing orphan visit' => [true, [ + new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(3), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(2), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), + ], Visit::forBasePath(Visitor::botInstance()), 3]; + } + + private function buildParams(bool $importOrphanVisits = false): ImportParams + { + return ImportSource::BITLY->toParamsWithCallableMap([ + ImportParams::IMPORT_SHORT_CODES_PARAM => static fn () => true, + ImportParams::IMPORT_ORPHAN_VISITS_PARAM => static fn () => $importOrphanVisits, + ]); + } + + public function setUpIoText(string $skippedText = 'Skipped', string $importedText = 'Imported'): stdClass + { + $counts = new stdClass(); + $counts->importedCount = 0; + $counts->skippedCount = 0; + + $this->io->method('text')->willReturnCallback( + function (string $output) use ($counts, $skippedText, $importedText): void { + if (str_contains($output, $skippedText)) { + $counts->skippedCount++; + } elseif (str_contains($output, $importedText)) { + $counts->importedCount++; + } + }, + ); + + return $counts; } } diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index 39e332c5..be036264 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlService; @@ -25,10 +23,8 @@ use function sprintf; class DeleteShortUrlServiceTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $em; - private ObjectProphecy $urlResolver; + private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrlResolverInterface $urlResolver; private string $shortCode; protected function setUp(): void @@ -38,10 +34,10 @@ class DeleteShortUrlServiceTest extends TestCase )); $this->shortCode = $shortUrl->getShortCode(); - $this->em = $this->prophesize(EntityManagerInterface::class); + $this->em = $this->createMock(EntityManagerInterface::class); - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->urlResolver->resolveShortUrl(Argument::cetera())->willReturn($shortUrl); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->urlResolver->method('resolveShortUrl')->willReturn($shortUrl); } /** @test */ @@ -63,13 +59,12 @@ class DeleteShortUrlServiceTest extends TestCase { $service = $this->createService(); - $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); - $flush = $this->em->flush()->willReturn(null); + $this->em->expects($this->once())->method('remove')->with($this->isInstanceOf(ShortUrl::class))->willReturn( + null, + ); + $this->em->expects($this->once())->method('flush')->with()->willReturn(null); $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode), true); - - $remove->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); } /** @test */ @@ -77,13 +72,12 @@ class DeleteShortUrlServiceTest extends TestCase { $service = $this->createService(false); - $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); - $flush = $this->em->flush()->willReturn(null); + $this->em->expects($this->once())->method('remove')->with($this->isInstanceOf(ShortUrl::class))->willReturn( + null, + ); + $this->em->expects($this->once())->method('flush')->with()->willReturn(null); $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); - - $remove->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); } /** @test */ @@ -91,20 +85,19 @@ class DeleteShortUrlServiceTest extends TestCase { $service = $this->createService(true, 100); - $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); - $flush = $this->em->flush()->willReturn(null); + $this->em->expects($this->once())->method('remove')->with($this->isInstanceOf(ShortUrl::class))->willReturn( + null, + ); + $this->em->expects($this->once())->method('flush')->with()->willReturn(null); $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); - - $remove->shouldHaveBeenCalledOnce(); - $flush->shouldHaveBeenCalledOnce(); } private function createService(bool $checkVisitsThreshold = true, int $visitsThreshold = 5): DeleteShortUrlService { - return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions( + return new DeleteShortUrlService($this->em, new DeleteShortUrlsOptions( $visitsThreshold, $checkVisitsThreshold, - ), $this->urlResolver->reveal()); + ), $this->urlResolver); } } diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index d874dbf5..026778ae 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -38,7 +38,7 @@ class ShortUrlTest extends TestCase public function provideInvalidShortUrls(): iterable { yield 'with custom slug' => [ - ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), + ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', ]; yield 'already persisted' => [ @@ -77,7 +77,7 @@ class ShortUrlTest extends TestCase */ public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], )); diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index 23f45506..cc18be07 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; @@ -16,19 +15,17 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; class ShortCodeUniquenessHelperTest extends TestCase { - use ProphecyTrait; - private ShortCodeUniquenessHelper $helper; - private ObjectProphecy $em; - private ObjectProphecy $shortUrl; + private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrl $shortUrl; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->helper = new ShortCodeUniquenessHelper($this->em); - $this->shortUrl = $this->prophesize(ShortUrl::class); - $this->shortUrl->getShortCode()->willReturn('abc123'); + $this->shortUrl = $this->createMock(ShortUrl::class); + $this->shortUrl->method('getShortCode')->willReturn('abc123'); } /** @@ -39,22 +36,22 @@ class ShortCodeUniquenessHelperTest extends TestCase { $callIndex = 0; $expectedCalls = 3; - $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUseWithLock( + $repo = $this->createMock(ShortUrlRepository::class); + $repo->expects($this->exactly($expectedCalls))->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123', $expectedAuthority), - )->will(function () use (&$callIndex, $expectedCalls) { + )->willReturnCallback(function () use (&$callIndex, $expectedCalls) { $callIndex++; return $callIndex < $expectedCalls; }); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $this->shortUrl->getDomain()->willReturn($domain); + $this->em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ShortUrl::class)->willReturn( + $repo, + ); + $this->shortUrl->method('getDomain')->willReturn($domain); + $this->shortUrl->expects($this->exactly($expectedCalls - 1))->method('regenerateShortCode')->with(); - $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl->reveal(), false); + $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl, false); self::assertTrue($result); - $this->shortUrl->regenerateShortCode()->shouldHaveBeenCalledTimes($expectedCalls - 1); - $getRepo->shouldBeCalledTimes($expectedCalls); - $shortCodeIsInUse->shouldBeCalledTimes($expectedCalls); } public function provideDomains(): iterable @@ -66,18 +63,16 @@ class ShortCodeUniquenessHelperTest extends TestCase /** @test */ public function inUseSlugReturnsError(): void { - $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUseWithLock( + $repo = $this->createMock(ShortUrlRepository::class); + $repo->expects($this->once())->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), )->willReturn(true); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $this->shortUrl->getDomain()->willReturn(null); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); + $this->shortUrl->method('getDomain')->willReturn(null); + $this->shortUrl->expects($this->never())->method('regenerateShortCode'); - $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl->reveal(), true); + $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl, true); self::assertFalse($result); - $this->shortUrl->regenerateShortCode()->shouldNotHaveBeenCalled(); - $getRepo->shouldBeCalledOnce(); - $shortCodeIsInUse->shouldBeCalledOnce(); } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index 1a077c19..cb94a9f1 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -30,7 +30,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase ?string $extraPath, ?bool $forwardQuery, ): void { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, ])); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index d46fbf92..b6d5a123 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -28,7 +28,7 @@ class ShortUrlStringifierTest extends TestCase public function provideConfigAndShortUrls(): iterable { - $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::fromMeta( + $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( ShortUrlCreation::fromRawData([ 'longUrl' => '', 'customSlug' => $shortCode, diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index b2cbfd85..2d48b294 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -4,24 +4,21 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelperTest extends TestCase { - use ProphecyTrait; - private ShortUrlTitleResolutionHelper $helper; - private ObjectProphecy $urlValidator; + private MockObject & UrlValidatorInterface $urlValidator; protected function setUp(): void { - $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); - $this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator->reveal()); + $this->urlValidator = $this->createMock(UrlValidatorInterface::class); + $this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator); } /** @@ -31,14 +28,18 @@ class ShortUrlTitleResolutionHelperTest extends TestCase public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void { $longUrl = 'http://foobar.com/12345/hello?foo=bar'; + $this->urlValidator->expects($this->exactly($validateWithTitleCallsNum))->method('validateUrlWithTitle')->with( + $longUrl, + $this->isFalse(), + ); + $this->urlValidator->expects($this->exactly($validateCallsNum))->method('validateUrl')->with( + $longUrl, + $this->isFalse(), + ); + $this->helper->processTitleAndValidateUrl( ShortUrlCreation::fromRawData(['longUrl' => $longUrl, 'title' => $title]), ); - - $this->urlValidator->validateUrlWithTitle($longUrl, false)->shouldHaveBeenCalledTimes( - $validateWithTitleCallsNum, - ); - $this->urlValidator->validateUrl($longUrl, false)->shouldHaveBeenCalledTimes($validateCallsNum); } public function provideTitles(): iterable diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 3367e4b2..355bec0e 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -9,10 +9,8 @@ use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -32,22 +30,20 @@ use function str_starts_with; class ExtraPathRedirectMiddlewareTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $resolver; - private ObjectProphecy $requestTracker; - private ObjectProphecy $redirectionBuilder; - private ObjectProphecy $redirectResponseHelper; - private ObjectProphecy $handler; + private MockObject & ShortUrlResolverInterface $resolver; + private MockObject & RequestTrackerInterface $requestTracker; + private MockObject & ShortUrlRedirectionBuilderInterface $redirectionBuilder; + private MockObject & RedirectResponseHelperInterface $redirectResponseHelper; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { - $this->resolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->requestTracker = $this->prophesize(RequestTrackerInterface::class); - $this->redirectionBuilder = $this->prophesize(ShortUrlRedirectionBuilderInterface::class); - $this->redirectResponseHelper = $this->prophesize(RedirectResponseHelperInterface::class); - $this->handler = $this->prophesize(RequestHandlerInterface::class); - $this->handler->handle(Argument::cetera())->willReturn(new RedirectResponse('')); + $this->resolver = $this->createMock(ShortUrlResolverInterface::class); + $this->requestTracker = $this->createMock(RequestTrackerInterface::class); + $this->redirectionBuilder = $this->createMock(ShortUrlRedirectionBuilderInterface::class); + $this->redirectResponseHelper = $this->createMock(RedirectResponseHelperInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + $this->handler->method('handle')->willReturn(new RedirectResponse('')); } /** @@ -63,14 +59,13 @@ class ExtraPathRedirectMiddlewareTest extends TestCase appendExtraPath: $appendExtraPath, multiSegmentSlugsEnabled: $multiSegmentEnabled, ); + $this->resolver->expects($this->never())->method('resolveEnabledShortUrl'); + $this->requestTracker->expects($this->never())->method('trackIfApplicable'); + $this->redirectionBuilder->expects($this->never())->method('buildShortUrlRedirect'); + $this->redirectResponseHelper->expects($this->never())->method('buildRedirectResponse'); + $this->handler->expects($this->once())->method('handle'); - $this->middleware($options)->process($request, $this->handler->reveal()); - - $this->handler->handle($request)->shouldHaveBeenCalledOnce(); - $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->middleware($options)->process($request, $this->handler); } public function provideNonRedirectingRequests(): iterable @@ -89,7 +84,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase RouteResult::class, RouteResult::fromRoute(new Route( '/foo', - $this->prophesize(MiddlewareInterface::class)->reveal(), + $this->createMock(MiddlewareInterface::class), ['GET'], RedirectAction::class, )), @@ -115,22 +110,20 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ): void { $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); - $type = $this->prophesize(NotFoundType::class); - $type->isRegularNotFound()->willReturn(true); - $type->isInvalidShortUrl()->willReturn(true); - $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + $type = $this->createMock(NotFoundType::class); + $type->method('isRegularNotFound')->willReturn(true); + $type->method('isInvalidShortUrl')->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) ->withUri(new Uri('/shortCode/bar/baz')); - $resolve = $this->resolver->resolveEnabledShortUrl( - Argument::that(fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode')), - )->willThrow(ShortUrlNotFoundException::class); + $this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with( + $this->callback(fn (ShortUrlIdentifier $id) => str_starts_with($id->shortCode, 'shortCode')), + )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); + $this->requestTracker->expects($this->never())->method('trackIfApplicable'); + $this->redirectionBuilder->expects($this->never())->method('buildShortUrlRedirect'); + $this->redirectResponseHelper->expects($this->never())->method('buildRedirectResponse'); - $this->middleware($options)->process($request, $this->handler->reveal()); - - $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); - $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->middleware($options)->process($request, $this->handler); } /** @@ -144,18 +137,17 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ): void { $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); - $type = $this->prophesize(NotFoundType::class); - $type->isRegularNotFound()->willReturn(true); - $type->isInvalidShortUrl()->willReturn(true); - $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) + $type = $this->createMock(NotFoundType::class); + $type->method('isRegularNotFound')->willReturn(true); + $type->method('isInvalidShortUrl')->willReturn(true); + $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); $shortUrl = ShortUrl::withLongUrl(''); - $identifier = Argument::that( - fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode'), - ); $currentIteration = 1; - $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->will( + $this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with( + $this->callback(fn (ShortUrlIdentifier $id) => str_starts_with($id->shortCode, 'shortCode')), + )->willReturnCallback( function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl { if ($expectedResolveCalls === $currentIteration) { return $shortUrl; @@ -165,18 +157,17 @@ class ExtraPathRedirectMiddlewareTest extends TestCase throw ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortUrl($shortUrl)); }, ); - $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], $expectedExtraPath) - ->willReturn('the_built_long_url'); - $buildResp = $this->redirectResponseHelper->buildRedirectResponse('the_built_long_url')->willReturn( - new RedirectResponse(''), - ); + $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( + $shortUrl, + [], + $expectedExtraPath, + )->willReturn('the_built_long_url'); + $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( + 'the_built_long_url', + )->willReturn(new RedirectResponse('')); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with($shortUrl, $request); - $this->middleware($options)->process($request, $this->handler->reveal()); - - $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); - $buildLongUrl->shouldHaveBeenCalledOnce(); - $buildResp->shouldHaveBeenCalledOnce(); - $this->requestTracker->trackIfApplicable($shortUrl, $request)->shouldHaveBeenCalledOnce(); + $this->middleware($options)->process($request, $this->handler); } public function provideResolves(): iterable @@ -188,10 +179,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase private function middleware(?UrlShortenerOptions $options = null): ExtraPathRedirectMiddleware { return new ExtraPathRedirectMiddleware( - $this->resolver->reveal(), - $this->requestTracker->reveal(), - $this->redirectionBuilder->reveal(), - $this->redirectResponseHelper->reveal(), + $this->resolver, + $this->requestTracker, + $this->redirectionBuilder, + $this->redirectResponseHelper, $options ?? new UrlShortenerOptions(appendExtraPath: true), ); } diff --git a/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php index 6a46f8e9..eb078902 100644 --- a/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/TrimTrailingSlashMiddlewareTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Middleware; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -21,13 +19,11 @@ use function Functional\const_function; class TrimTrailingSlashMiddlewareTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $requestHandler; + private MockObject & RequestHandlerInterface $requestHandler; protected function setUp(): void { - $this->requestHandler = $this->prophesize(RequestHandlerInterface::class); + $this->requestHandler = $this->createMock(RequestHandlerInterface::class); } /** @@ -40,9 +36,11 @@ class TrimTrailingSlashMiddlewareTest extends TestCase callable $assertions, ): void { $arg = compose($assertions, const_function(true)); + $this->requestHandler->expects($this->once())->method('handle')->with($this->callback($arg))->willReturn( + new Response(), + ); - $this->requestHandler->handle(Argument::that($arg))->willReturn(new Response()); - $this->middleware($trailingSlashEnabled)->process($inputRequest, $this->requestHandler->reveal()); + $this->middleware($trailingSlashEnabled)->process($inputRequest, $this->requestHandler); } public function provideRequests(): iterable diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 0f9dc419..51457264 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -146,4 +146,26 @@ class ShortUrlCreationTest extends TestCase yield [str_pad('', 600, 'd'), str_pad('', 512, 'd')]; yield [str_pad('', 800, 'e'), str_pad('', 512, 'e')]; } + + /** + * @test + * @dataProvider provideDomains + */ + public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void + { + $meta = ShortUrlCreation::fromRawData([ + 'domain' => $domain, + 'longUrl' => '', + ]); + + self::assertSame($expectedDomain, $meta->getDomain()); + } + + public function provideDomains(): iterable + { + yield 'null domain' => [null, null]; + yield 'empty domain' => ['', null]; + yield 'trimmable domain' => [' ', null]; + yield 'valid domain' => ['doma.in', 'doma.in']; + } } diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 31a8f4f1..684c1528 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -5,26 +5,23 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Paginator\Adapter; use Cake\Chronos\Chronos; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlRepositoryAdapterTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $repo; + private MockObject & ShortUrlListRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlListRepositoryInterface::class); } /** @@ -45,13 +42,14 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'endDate' => $endDate, 'orderBy' => $orderBy, ]); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, null); - $orderBy = $params->orderBy(); - $dateRange = $params->dateRange(); + $adapter = new ShortUrlRepositoryAdapter($this->repo, $params, null, ''); + $orderBy = $params->orderBy; + $dateRange = $params->dateRange; - $this->repo->findList( + $this->repo->expects($this->once())->method('findList')->with( new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, TagsMode::ANY, $dateRange), - )->shouldBeCalledOnce(); + ); + $adapter->getSlice(5, 10); } @@ -72,12 +70,12 @@ class ShortUrlRepositoryAdapterTest extends TestCase 'endDate' => $endDate, ]); $apiKey = ApiKey::create(); - $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey); - $dateRange = $params->dateRange(); + $adapter = new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, ''); + $dateRange = $params->dateRange; - $this->repo->countList( - new ShortUrlsCountFiltering($searchTerm, $tags, TagsMode::ANY, $dateRange, $apiKey), - )->shouldBeCalledOnce(); + $this->repo->expects($this->once())->method('countList')->with( + new ShortUrlsCountFiltering($searchTerm, $tags, TagsMode::ANY, $dateRange, apiKey: $apiKey), + ); $adapter->getNbResults(); } diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 997178c5..37a9f2e2 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Resolver; use Doctrine\Common\EventManager; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -20,26 +18,22 @@ use function count; class PersistenceShortUrlRelationResolverTest extends TestCase { - use ProphecyTrait; - private PersistenceShortUrlRelationResolver $resolver; - private ObjectProphecy $em; + private MockObject & EntityManagerInterface $em; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->em->getEventManager()->willReturn(new EventManager()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->em->method('getEventManager')->willReturn(new EventManager()); - $this->resolver = new PersistenceShortUrlRelationResolver($this->em->reveal()); + $this->resolver = new PersistenceShortUrlRelationResolver($this->em); } /** @test */ public function returnsEmptyWhenNoDomainIsProvided(): void { - $getRepository = $this->em->getRepository(Domain::class); - + $this->em->expects($this->never())->method('getRepository')->with(Domain::class); self::assertNull($this->resolver->resolveDomain(null)); - $getRepository->shouldNotHaveBeenCalled(); } /** @@ -48,9 +42,9 @@ class PersistenceShortUrlRelationResolverTest extends TestCase */ public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void { - $repo = $this->prophesize(DomainRepositoryInterface::class); - $findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); - $getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->expects($this->once())->method('findOneBy')->with(['authority' => $authority])->willReturn($foundDomain); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $result = $this->resolver->resolveDomain($authority); @@ -59,8 +53,6 @@ class PersistenceShortUrlRelationResolverTest extends TestCase } self::assertInstanceOf(Domain::class, $result); self::assertEquals($authority, $result->getAuthority()); - $findDomain->shouldHaveBeenCalledOnce(); - $getRepository->shouldHaveBeenCalledOnce(); } public function provideFoundDomains(): iterable @@ -79,21 +71,22 @@ class PersistenceShortUrlRelationResolverTest extends TestCase { $expectedPersistedTags = count($expectedTags); - $tagRepo = $this->prophesize(TagRepositoryInterface::class); - $findTag = $tagRepo->findOneBy(Argument::type('array'))->will(function (array $args): ?Tag { - ['name' => $name] = $args[0]; + $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo->expects($this->exactly($expectedPersistedTags))->method('findOneBy')->with( + $this->isType('array'), + )->willReturnCallback(function (array $criteria): ?Tag { + ['name' => $name] = $criteria; return $name === 'foo' ? new Tag($name) : null; }); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); - $persist = $this->em->persist(Argument::type(Tag::class)); + $this->em->expects($this->once())->method('getRepository')->with(Tag::class)->willReturn($tagRepo); + $this->em->expects($this->exactly($expectedPersistedTags))->method('persist')->with( + $this->isInstanceOf(Tag::class), + ); $result = $this->resolver->resolveTags($tags); self::assertCount($expectedPersistedTags, $result); self::assertEquals($expectedTags, $result->toArray()); - $findTag->shouldHaveBeenCalledTimes($expectedPersistedTags); - $getRepo->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes($expectedPersistedTags); } public function provideTags(): iterable @@ -105,25 +98,20 @@ class PersistenceShortUrlRelationResolverTest extends TestCase /** @test */ public function returnsEmptyCollectionWhenProvidingEmptyListOfTags(): void { - $tagRepo = $this->prophesize(TagRepositoryInterface::class); - $findTag = $tagRepo->findOneBy(Argument::type('array'))->willReturn(null); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); - $persist = $this->em->persist(Argument::type(Tag::class)); + $this->em->expects($this->never())->method('getRepository')->with(Tag::class); + $this->em->expects($this->never())->method('persist'); $result = $this->resolver->resolveTags([]); self::assertEmpty($result); - $findTag->shouldNotHaveBeenCalled(); - $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()); + $repo = $this->createMock(DomainRepositoryInterface::class); + $repo->expects($this->exactly(3))->method('findOneBy')->with($this->isType('array'))->willReturn(null); + $this->em->method('getRepository')->with(Domain::class)->willReturn($repo); $authority = 'foo.com'; $domain1 = $this->resolver->resolveDomain($authority); @@ -140,11 +128,9 @@ class PersistenceShortUrlRelationResolverTest extends TestCase /** @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 { - }); + $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo->expects($this->exactly(6))->method('findOneBy')->with($this->isType('array'))->willReturn(null); + $this->em->method('getRepository')->with(Tag::class)->willReturn($tagRepo); $tags = ['foo', 'bar']; [$foo1, $bar1] = $this->resolver->resolveTags($tags); diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php new file mode 100644 index 00000000..be8eb852 --- /dev/null +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -0,0 +1,53 @@ +repo = $this->createMock(ShortUrlListRepositoryInterface::class); + $this->service = new ShortUrlListService($this->repo, new UrlShortenerOptions()); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void + { + $list = [ + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ShortUrl::createEmpty(), + ]; + + $this->repo->expects($this->once())->method('findList')->willReturn($list); + $this->repo->expects($this->once())->method('countList')->willReturn(count($list)); + + $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); + + self::assertCount(4, $paginator); + self::assertCount(4, $paginator->getCurrentPageResults()); + } +} diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 5ba2d514..9c2bcab3 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -7,9 +7,8 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; @@ -27,15 +26,16 @@ use function range; class ShortUrlResolverTest extends TestCase { use ApiKeyHelpersTrait; - use ProphecyTrait; private ShortUrlResolver $urlResolver; - private ObjectProphecy $em; + private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrlRepositoryInterface $repo; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->urlResolver = new ShortUrlResolver($this->em->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->urlResolver = new ShortUrlResolver($this->em); } /** @@ -48,15 +48,14 @@ class ShortUrlResolverTest extends TestCase $shortCode = $shortUrl->getShortCode(); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn($shortUrl); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findOne')->with($identifier, $apiKey?->spec())->willReturn( + $shortUrl, + ); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $result = $this->urlResolver->resolveShortUrl($identifier, $apiKey); self::assertSame($shortUrl, $result); - $findOne->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @@ -68,13 +67,10 @@ class ShortUrlResolverTest extends TestCase $shortCode = 'abc123'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOne = $repo->findOne($identifier, $apiKey?->spec())->willReturn(null); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal(), $apiKey); + $this->repo->expects($this->once())->method('findOne')->with($identifier, $apiKey?->spec())->willReturn(null); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); - $findOne->shouldBeCalledOnce(); - $getRepo->shouldBeCalledOnce(); $this->urlResolver->resolveShortUrl($identifier, $apiKey); } @@ -85,17 +81,14 @@ class ShortUrlResolverTest extends TestCase $shortUrl = ShortUrl::withLongUrl('expected_url'); $shortCode = $shortUrl->getShortCode(); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOneByShortCode = $repo->findOneWithDomainFallback( + $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), )->willReturn($shortUrl); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $result = $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); self::assertSame($shortUrl, $result); - $findOneByShortCode->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @@ -106,15 +99,12 @@ class ShortUrlResolverTest extends TestCase { $shortCode = $shortUrl->getShortCode(); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $findOneByShortCode = $repo->findOneWithDomainFallback( + $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), )->willReturn($shortUrl); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); - $findOneByShortCode->shouldBeCalledOnce(); - $getRepo->shouldBeCalledOnce(); $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); } @@ -124,7 +114,7 @@ class ShortUrlResolverTest extends TestCase $now = Chronos::now(); yield 'maxVisits reached' => [(function () { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), @@ -132,14 +122,14 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; - yield 'future validSince' => [ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => ''], ))]; - yield 'past validUntil' => [ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => ''], ))]; yield 'mixed' => [(function () use ($now) { - $shortUrl = ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => 3, 'validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => '', diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 9037be60..9cc0d955 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -6,75 +6,44 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; -use function count; - class ShortUrlServiceTest extends TestCase { use ApiKeyHelpersTrait; - use ProphecyTrait; private ShortUrlService $service; - private ObjectProphecy $em; - private ObjectProphecy $urlResolver; - private ObjectProphecy $titleResolutionHelper; + private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrlResolverInterface $urlResolver; + private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->em->persist(Argument::any())->willReturn(null); - $this->em->flush()->willReturn(null); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->em->method('persist')->willReturn(null); + $this->em->method('flush')->willReturn(null); - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class); + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->service = new ShortUrlService( - $this->em->reveal(), - $this->urlResolver->reveal(), - $this->titleResolutionHelper->reveal(), + $this->em, + $this->urlResolver, + $this->titleResolutionHelper, new SimpleShortUrlRelationResolver(), ); } - /** - * @test - * @dataProvider provideAdminApiKeys - */ - public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void - { - $list = [ - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ]; - - $repo = $this->prophesize(ShortUrlRepository::class); - $repo->findList(Argument::cetera())->willReturn($list)->shouldBeCalledOnce(); - $repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce(); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - - $paginator = $this->service->listShortUrls(ShortUrlsParams::emptyInstance(), $apiKey); - - self::assertCount(4, $paginator); - self::assertCount(4, $paginator->getCurrentPageResults()); - } - /** * @test * @dataProvider provideShortUrlEdits @@ -87,15 +56,15 @@ class ShortUrlServiceTest extends TestCase $originalLongUrl = 'originalLongUrl'; $shortUrl = ShortUrl::withLongUrl($originalLongUrl); - $findShortUrl = $this->urlResolver->resolveShortUrl( + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), $apiKey, )->willReturn($shortUrl); - $flush = $this->em->flush()->willReturn(null); - $processTitle = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit)->willReturn( - $shortUrlEdit, - ); + $this->titleResolutionHelper->expects($this->exactly($expectedValidateCalls)) + ->method('processTitleAndValidateUrl') + ->with($shortUrlEdit) + ->willReturn($shortUrlEdit); $result = $this->service->updateShortUrl( ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), @@ -108,9 +77,6 @@ class ShortUrlServiceTest extends TestCase self::assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); - $findShortUrl->shouldHaveBeenCalled(); - $flush->shouldHaveBeenCalled(); - $processTitle->shouldHaveBeenCalledTimes($expectedValidateCalls); } public function provideShortUrlEdits(): iterable diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index 376e3d03..c9df4e38 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -43,7 +43,7 @@ class ShortUrlDataTransformerTest extends TestCase 'validUntil' => null, 'maxVisits' => null, ]]; - yield 'max visits only' => [ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => $maxVisits, 'longUrl' => '', ])), [ @@ -52,7 +52,7 @@ class ShortUrlDataTransformerTest extends TestCase 'maxVisits' => $maxVisits, ]]; yield 'max visits and valid since' => [ - ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + ShortUrl::create(ShortUrlCreation::fromRawData( ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => ''], )), [ @@ -62,7 +62,7 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'both dates' => [ - ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + ShortUrl::create(ShortUrlCreation::fromRawData( ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => ''], )), [ @@ -72,7 +72,7 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'everything' => [ - ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + ShortUrl::create(ShortUrlCreation::fromRawData( ['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits, 'longUrl' => ''], )), [ diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index 9bf5a4d1..d59de634 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -5,11 +5,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; -use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -22,42 +20,29 @@ use Shlinkio\Shlink\Core\ShortUrl\UrlShortener; class UrlShortenerTest extends TestCase { - use ProphecyTrait; - private UrlShortener $urlShortener; - private ObjectProphecy $em; - private ObjectProphecy $titleResolutionHelper; - private ObjectProphecy $shortCodeHelper; + private MockObject & EntityManager $em; + private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; + private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; protected function setUp(): void { - $this->titleResolutionHelper = $this->prophesize(ShortUrlTitleResolutionHelperInterface::class); - $this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->willReturnArgument(); + $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); + $this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class); - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->em->persist(Argument::any())->will(function ($arguments): void { - /** @var ShortUrl $shortUrl */ - [$shortUrl] = $arguments; - $shortUrl->setId('10'); - }); - $this->em->wrapInTransaction(Argument::type('callable'))->will(function (array $args) { - /** @var callable $callback */ - [$callback] = $args; - - return $callback(); - }); - $repo = $this->prophesize(ShortUrlRepository::class); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - - $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class); - $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + // FIXME Should use the interface, but it doe snot define wrapInTransaction explicitly + $this->em = $this->createMock(EntityManager::class); + $this->em->method('persist')->willReturnCallback(fn (ShortUrl $shortUrl) => $shortUrl->setId('10')); + $this->em->method('wrapInTransaction')->with($this->isType('callable'))->willReturnCallback( + fn (callable $callback) => $callback(), + ); $this->urlShortener = new UrlShortener( - $this->titleResolutionHelper->reveal(), - $this->em->reveal(), + $this->titleResolutionHelper, + $this->em, new SimpleShortUrlRelationResolver(), - $this->shortCodeHelper->reveal(), - $this->prophesize(EventDispatcherInterface::class)->reveal(), + $this->shortCodeHelper, + $this->createMock(EventDispatcherInterface::class), ); } @@ -66,23 +51,31 @@ class UrlShortenerTest extends TestCase { $longUrl = 'http://foobar.com/12345/hello?foo=bar'; $meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]); + $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $meta, + )->willReturnArgument(0); + $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); + $shortUrl = $this->urlShortener->shorten($meta); self::assertEquals($longUrl, $shortUrl->getLongUrl()); - $this->titleResolutionHelper->processTitleAndValidateUrl($meta)->shouldHaveBeenCalledOnce(); } /** @test */ public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void { - $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(false); + $meta = ShortUrlCreation::fromRawData( + ['customSlug' => 'custom-slug', 'longUrl' => 'http://foobar.com/12345/hello?foo=bar'], + ); + + $this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false); + $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $meta, + )->willReturnArgument(0); - $ensureUniqueness->shouldBeCalledOnce(); $this->expectException(NonUniqueSlugException::class); - $this->urlShortener->shorten(ShortUrlCreation::fromRawData( - ['customSlug' => 'custom-slug', 'longUrl' => 'http://foobar.com/12345/hello?foo=bar'], - )); + $this->urlShortener->shorten($meta); } /** @@ -91,16 +84,14 @@ class UrlShortenerTest extends TestCase */ public function existingShortUrlIsReturnedWhenRequested(ShortUrlCreation $meta, ShortUrl $expected): void { - $repo = $this->prophesize(ShortUrlRepository::class); - $findExisting = $repo->findOneMatching(Argument::cetera())->willReturn($expected); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $repo = $this->createMock(ShortUrlRepository::class); + $repo->expects($this->once())->method('findOneMatching')->willReturn($expected); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); + $this->titleResolutionHelper->expects($this->never())->method('processTitleAndValidateUrl'); + $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); $result = $this->urlShortener->shorten($meta); - $findExisting->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); - $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->titleResolutionHelper->processTitleAndValidateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); self::assertSame($expected, $result); } @@ -116,17 +107,17 @@ class UrlShortenerTest extends TestCase ), ShortUrl::withLongUrl($url)]; yield [ ShortUrlCreation::fromRawData(['findIfExists' => true, 'longUrl' => $url, 'tags' => ['foo', 'bar']]), - ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['longUrl' => $url, 'tags' => ['foo', 'bar']])), + ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => $url, 'tags' => ['foo', 'bar']])), ]; yield [ ShortUrlCreation::fromRawData(['findIfExists' => true, 'maxVisits' => 3, 'longUrl' => $url]), - ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => $url])), + ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => $url])), ]; yield [ ShortUrlCreation::fromRawData( ['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url], ), - ShortUrl::fromMeta( + ShortUrl::create( ShortUrlCreation::fromRawData(['validSince' => Chronos::parse('2017-01-01'), 'longUrl' => $url]), ), ]; @@ -134,13 +125,13 @@ class UrlShortenerTest extends TestCase ShortUrlCreation::fromRawData( ['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url], ), - ShortUrl::fromMeta( + ShortUrl::create( ShortUrlCreation::fromRawData(['validUntil' => Chronos::parse('2017-01-01'), 'longUrl' => $url]), ), ]; yield [ ShortUrlCreation::fromRawData(['findIfExists' => true, 'domain' => 'example.com', 'longUrl' => $url]), - ShortUrl::fromMeta(ShortUrlCreation::fromRawData(['domain' => 'example.com', 'longUrl' => $url])), + ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 'example.com', 'longUrl' => $url])), ]; yield [ ShortUrlCreation::fromRawData([ @@ -150,7 +141,7 @@ class UrlShortenerTest extends TestCase 'longUrl' => $url, 'tags' => ['baz', 'foo', 'bar'], ]), - ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + ShortUrl::create(ShortUrlCreation::fromRawData([ 'validUntil' => Chronos::parse('2017-01-01'), 'maxVisits' => 4, 'longUrl' => $url, diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php index f2f573a4..fe105ce1 100644 --- a/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php +++ b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php @@ -4,45 +4,37 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface; class TagsInfoPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - private TagsInfoPaginatorAdapter $adapter; - private ObjectProphecy $repo; + private MockObject & TagRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->prophesize(TagRepositoryInterface::class); - $this->adapter = new TagsInfoPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null); + $this->repo = $this->createMock(TagRepositoryInterface::class); + $this->adapter = new TagsInfoPaginatorAdapter($this->repo, TagsParams::fromRawData([]), null); } /** @test */ public function getSliceIsDelegatedToRepository(): void { - $findTags = $this->repo->findTagsWithInfo(Argument::cetera())->willReturn([]); - + $this->repo->expects($this->once())->method('findTagsWithInfo')->willReturn([]); $this->adapter->getSlice(1, 1); - - $findTags->shouldHaveBeenCalledOnce(); } /** @test */ public function getNbResultsIsDelegatedToRepository(): void { - $match = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(3); + $this->repo->expects($this->once())->method('matchSingleScalarResult')->willReturn(3); $result = $this->adapter->getNbResults(); self::assertEquals(3, $result); - $match->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index ccad1ec1..a3b36215 100644 --- a/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -4,34 +4,27 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface; class TagsPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - private TagsPaginatorAdapter $adapter; - private ObjectProphecy $repo; + private MockObject & TagRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->prophesize(TagRepositoryInterface::class); - $this->adapter = new TagsPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null); + $this->repo = $this->createMock(TagRepositoryInterface::class); + $this->adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData([]), null); } /** @test */ public function getSliceDelegatesToRepository(): void { - $match = $this->repo->match(Argument::cetera())->willReturn([]); - + $this->repo->expects($this->once())->method('match')->willReturn([]); $this->adapter->getSlice(1, 1); - - $match->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 59d22bba..069bca20 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Tag; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; @@ -27,19 +25,18 @@ use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; class TagServiceTest extends TestCase { use ApiKeyHelpersTrait; - use ProphecyTrait; private TagService $service; - private ObjectProphecy $em; - private ObjectProphecy $repo; + private MockObject & EntityManagerInterface $em; + private MockObject & TagRepository $repo; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->repo = $this->prophesize(TagRepository::class); - $this->em->getRepository(Tag::class)->willReturn($this->repo->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->repo = $this->createMock(TagRepository::class); + $this->em->method('getRepository')->with(Tag::class)->willReturn($this->repo); - $this->service = new TagService($this->em->reveal()); + $this->service = new TagService($this->em); } /** @test */ @@ -47,14 +44,12 @@ class TagServiceTest extends TestCase { $expected = [new Tag('foo'), new Tag('bar')]; - $match = $this->repo->match(Argument::cetera())->willReturn($expected); - $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); + $this->repo->expects($this->once())->method('match')->willReturn($expected); + $this->repo->expects($this->once())->method('matchSingleScalarResult')->willReturn(2); $result = $this->service->listTags(TagsParams::fromRawData([])); self::assertEquals($expected, $result->getCurrentPageResults()); - $match->shouldHaveBeenCalled(); - $count->shouldHaveBeenCalled(); } /** @@ -69,14 +64,14 @@ class TagServiceTest extends TestCase ): void { $expected = [new TagInfo('foo', 1, 1), new TagInfo('bar', 3, 10)]; - $find = $this->repo->findTagsWithInfo($expectedFiltering)->willReturn($expected); - $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); + $this->repo->expects($this->once())->method('findTagsWithInfo')->with($expectedFiltering)->willReturn( + $expected, + ); + $this->repo->expects($this->exactly($countCalls))->method('matchSingleScalarResult')->willReturn(2); $result = $this->service->tagsInfo($params, $apiKey); self::assertEquals($expected, $result->getCurrentPageResults()); - $find->shouldHaveBeenCalledOnce(); - $count->shouldHaveBeenCalledTimes($countCalls); } public function provideApiKeysAndSearchTerm(): iterable @@ -113,21 +108,17 @@ class TagServiceTest extends TestCase */ public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void { - $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4); - + $this->repo->expects($this->once())->method('deleteByName')->with(['foo', 'bar'])->willReturn(4); $this->service->deleteTags(['foo', 'bar'], $apiKey); - - $delete->shouldHaveBeenCalled(); } /** @test */ public function deleteTagsThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void { - $delete = $this->repo->deleteByName(['foo', 'bar']); + $this->repo->expects($this->never())->method('deleteByName'); $this->expectException(ForbiddenTagOperationException::class); $this->expectExceptionMessage('You are not allowed to delete tags'); - $delete->shouldNotBeCalled(); $this->service->deleteTags( ['foo', 'bar'], @@ -141,9 +132,7 @@ class TagServiceTest extends TestCase */ public function renameInvalidTagThrowsException(?ApiKey $apiKey): void { - $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null); - - $find->shouldBeCalled(); + $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); $this->expectException(TagNotFoundException::class); $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); @@ -157,17 +146,14 @@ class TagServiceTest extends TestCase { $expected = new Tag('foo'); - $find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected); - $countTags = $this->repo->count(Argument::cetera())->willReturn($count); - $flush = $this->em->flush()->willReturn(null); + $this->repo->expects($this->once())->method('findOneBy')->willReturn($expected); + $this->repo->expects($this->exactly($count > 0 ? 0 : 1))->method('count')->willReturn($count); + $this->em->expects($this->once())->method('flush'); $tag = $this->service->renameTag(TagRenaming::fromNames($oldName, $newName)); self::assertSame($expected, $tag); self::assertEquals($newName, (string) $tag); - $find->shouldHaveBeenCalled(); - $flush->shouldHaveBeenCalled(); - $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); } public function provideValidRenames(): iterable @@ -182,13 +168,10 @@ class TagServiceTest extends TestCase */ public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void { - $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); - $countTags = $this->repo->count(Argument::cetera())->willReturn(1); - $flush = $this->em->flush(Argument::any())->willReturn(null); + $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); + $this->repo->expects($this->once())->method('count')->willReturn(1); + $this->em->expects($this->never())->method('flush'); - $find->shouldBeCalled(); - $countTags->shouldBeCalled(); - $flush->shouldNotBeCalled(); $this->expectException(TagConflictException::class); $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); @@ -197,11 +180,10 @@ class TagServiceTest extends TestCase /** @test */ public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void { - $getRepo = $this->em->getRepository(Tag::class); + $this->em->expects($this->never())->method('getRepository')->with(Tag::class); $this->expectExceptionMessage(ForbiddenTagOperationException::class); $this->expectExceptionMessage('You are not allowed to rename tags'); - $getRepo->shouldNotBeCalled(); $this->service->renameTag( TagRenaming::fromNames('foo', 'bar'), diff --git a/module/Core/test/Util/DoctrineBatchHelperTest.php b/module/Core/test/Util/DoctrineBatchHelperTest.php index f6f9981d..2fc0f985 100644 --- a/module/Core/test/Util/DoctrineBatchHelperTest.php +++ b/module/Core/test/Util/DoctrineBatchHelperTest.php @@ -5,23 +5,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Util; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelper; class DoctrineBatchHelperTest extends TestCase { - use ProphecyTrait; - private DoctrineBatchHelper $helper; - private ObjectProphecy $em; + private MockObject & EntityManagerInterface $em; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->helper = new DoctrineBatchHelper($this->em->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->helper = new DoctrineBatchHelper($this->em); } /** @@ -33,17 +30,17 @@ class DoctrineBatchHelperTest extends TestCase int $batchSize, int $expectedCalls, ): void { + $this->em->expects($this->once())->method('beginTransaction'); + $this->em->expects($this->once())->method('commit'); + $this->em->expects($this->never())->method('rollback'); + $this->em->expects($this->exactly($expectedCalls))->method('flush'); + $this->em->expects($this->exactly($expectedCalls))->method('clear'); + $wrappedIterable = $this->helper->wrapIterable($iterable, $batchSize); foreach ($wrappedIterable as $item) { // Iterable needs to be iterated for the logic to be invoked } - - $this->em->beginTransaction()->shouldHaveBeenCalledOnce(); - $this->em->commit()->shouldHaveBeenCalledOnce(); - $this->em->rollback()->shouldNotHaveBeenCalled(); - $this->em->flush()->shouldHaveBeenCalledTimes($expectedCalls); - $this->em->clear()->shouldHaveBeenCalledTimes($expectedCalls); } public function provideIterables(): iterable @@ -56,15 +53,14 @@ class DoctrineBatchHelperTest extends TestCase /** @test */ public function transactionIsRolledBackWhenAnErrorOccurs(): void { - $flush = $this->em->flush()->willThrow(RuntimeException::class); + $this->em->expects($this->once())->method('flush')->willThrowException(new RuntimeException()); + $this->em->expects($this->once())->method('beginTransaction'); + $this->em->expects($this->never())->method('commit'); + $this->em->expects($this->once())->method('rollback'); $wrappedIterable = $this->helper->wrapIterable([1, 2, 3], 1); self::expectException(RuntimeException::class); - $flush->shouldBeCalledOnce(); - $this->em->beginTransaction()->shouldBeCalledOnce(); - $this->em->commit()->shouldNotBeCalled(); - $this->em->rollback()->shouldBeCalledOnce(); foreach ($wrappedIterable as $item) { // Iterable needs to be iterated for the logic to be invoked diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index cc13bd2c..90ab2fd7 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -7,35 +7,30 @@ namespace ShlinkioTest\Shlink\Core\Util; use Fig\Http\Message\RequestMethodInterface; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Response; use Laminas\Diactoros\Stream; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Util\UrlValidator; class UrlValidatorTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $httpClient; + private MockObject & ClientInterface $httpClient; protected function setUp(): void { - $this->httpClient = $this->prophesize(ClientInterface::class); + $this->httpClient = $this->createMock(ClientInterface::class); } /** @test */ public function exceptionIsThrownWhenUrlIsInvalid(): void { - $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); - - $request->shouldBeCalledOnce(); + $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); $this->expectException(InvalidUrlException::class); $this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true); @@ -46,10 +41,10 @@ class UrlValidatorTest extends TestCase { $expectedUrl = 'http://foobar.com'; - $request = $this->httpClient->request( + $this->httpClient->expects($this->once())->method('request')->with( RequestMethodInterface::METHOD_GET, $expectedUrl, - Argument::that(function (array $options) { + $this->callback(function (array $options) { Assert::assertArrayHasKey(RequestOptions::ALLOW_REDIRECTS, $options); Assert::assertEquals(['max' => 15], $options[RequestOptions::ALLOW_REDIRECTS]); Assert::assertArrayHasKey(RequestOptions::IDN_CONVERSION, $options); @@ -62,92 +57,91 @@ class UrlValidatorTest extends TestCase )->willReturn(new Response()); $this->urlValidator()->validateUrl($expectedUrl, true); - - $request->shouldHaveBeenCalledOnce(); } /** @test */ public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void { - $request = $this->httpClient->request(Argument::cetera())->willReturn(new Response()); - + $this->httpClient->expects($this->never())->method('request'); $this->urlValidator()->validateUrl('', false); - - $request->shouldNotHaveBeenCalled(); } /** @test */ public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void { - $request = $this->httpClient->request(Argument::cetera())->willThrow(ClientException::class); + $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); self::assertNull($result); - $request->shouldHaveBeenCalledOnce(); } /** @test */ public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void { - $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); + $this->httpClient->expects($this->never())->method('request'); $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); self::assertNull($result); - $request->shouldNotHaveBeenCalled(); } /** @test */ public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void { - $request = $this->httpClient->request(RequestMethodInterface::METHOD_HEAD, Argument::cetera())->willReturn( - $this->respWithTitle(), - ); + $this->httpClient->expects($this->once())->method('request')->with( + RequestMethodInterface::METHOD_HEAD, + $this->anything(), + $this->anything(), + )->willReturn($this->respWithTitle()); $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); - $request->shouldHaveBeenCalledOnce(); } /** @test */ public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void { - $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( - $this->respWithTitle(), - ); + $this->httpClient->expects($this->once())->method('request')->with( + RequestMethodInterface::METHOD_GET, + $this->anything(), + $this->anything(), + )->willReturn($this->respWithTitle()); $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertEquals('Resolved "title"', $result); - $request->shouldHaveBeenCalledOnce(); } /** @test */ public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void { - $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( - new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream']), - ); + $this->httpClient->expects($this->once())->method('request')->with( + RequestMethodInterface::METHOD_GET, + $this->anything(), + $this->anything(), + )->willReturn(new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream'])); $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); - $request->shouldHaveBeenCalledOnce(); } /** @test */ public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void { - $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( + $this->httpClient->expects($this->once())->method('request')->with( + RequestMethodInterface::METHOD_GET, + $this->anything(), + $this->anything(), + )->willReturn( new Response($this->createStreamWithContent('No title'), 200, ['Content-Type' => 'text/html']), ); $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); self::assertNull($result); - $request->shouldHaveBeenCalledOnce(); } private function respWithTitle(): Response @@ -165,11 +159,17 @@ class UrlValidatorTest extends TestCase return $body; } - public function urlValidator(bool $autoResolveTitles = false): UrlValidator + private function clientException(): ClientException { - return new UrlValidator( - $this->httpClient->reveal(), - new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles), + return new ClientException( + '', + new Request(RequestMethodInterface::METHOD_GET, ''), + new Response(), ); } + + public function urlValidator(bool $autoResolveTitles = false): UrlValidator + { + return new UrlValidator($this->httpClient, new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles)); + } } diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index 6cdd421d..ba0d70c4 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -6,12 +6,8 @@ namespace ShlinkioTest\Shlink\Core\Visit\Geolocation; use Doctrine\ORM\EntityManager; use Exception; -use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\MethodProphecy; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -19,32 +15,27 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitGeolocationHelperInterface; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitLocationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -use function array_shift; use function count; use function floor; -use function func_get_args; use function Functional\map; use function range; use function sprintf; class VisitLocatorTest extends TestCase { - use ProphecyTrait; - private VisitLocator $visitService; - private ObjectProphecy $em; - private ObjectProphecy $repo; + private MockObject & EntityManager $em; + private MockObject & VisitLocationRepositoryInterface $repo; protected function setUp(): void { - $this->em = $this->prophesize(EntityManager::class); - $this->repo = $this->prophesize(VisitRepositoryInterface::class); - $this->em->getRepository(Visit::class)->willReturn($this->repo->reveal()); + $this->em = $this->createMock(EntityManager::class); + $this->repo = $this->createMock(VisitLocationRepositoryInterface::class); - $this->visitService = new VisitLocator($this->em->reveal()); + $this->visitService = new VisitLocator($this->em, $this->repo); } /** @@ -61,14 +52,13 @@ class VisitLocatorTest extends TestCase Visit::forValidShortUrl(ShortUrl::withLongUrl(sprintf('short_code_%s', $i)), Visitor::emptyInstance()), ); - $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); + $this->repo->expects($this->once())->method($expectedRepoMethodName)->willReturn($unlocatedVisits); - $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { - }); - $flush = $this->em->flush()->will(function (): void { - }); - $clear = $this->em->clear()->will(function (): void { - }); + $this->em->expects($this->exactly(count($unlocatedVisits)))->method('persist')->with( + $this->isInstanceOf(Visit::class), + ); + $this->em->expects($this->exactly((int) floor(count($unlocatedVisits) / 200) + 1))->method('flush'); + $this->em->expects($this->exactly((int) floor(count($unlocatedVisits) / 200) + 1))->method('clear'); $this->visitService->{$serviceMethodName}(new class implements VisitGeolocationHelperInterface { public function geolocateVisit(Visit $visit): Location @@ -78,17 +68,8 @@ class VisitLocatorTest extends TestCase public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void { - $args = func_get_args(); - - Assert::assertInstanceOf(VisitLocation::class, array_shift($args)); - Assert::assertInstanceOf(Visit::class, array_shift($args)); } }); - - $findVisits->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes(count($unlocatedVisits)); - $flush->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); - $clear->shouldHaveBeenCalledTimes(floor(count($unlocatedVisits) / 200) + 1); } public function provideMethodNames(): iterable @@ -111,18 +92,17 @@ class VisitLocatorTest extends TestCase Visit::forValidShortUrl(ShortUrl::withLongUrl('foo'), Visitor::emptyInstance()), ]; - $findVisits = $this->mockRepoMethod($expectedRepoMethodName)->willReturn($unlocatedVisits); + $this->repo->expects($this->once())->method($expectedRepoMethodName)->willReturn($unlocatedVisits); - $persist = $this->em->persist(Argument::type(Visit::class))->will(function (): void { - }); - $flush = $this->em->flush()->will(function (): void { - }); - $clear = $this->em->clear()->will(function (): void { - }); + $this->em->expects($this->exactly($isNonLocatableAddress ? 1 : 0))->method('persist')->with( + $this->isInstanceOf(Visit::class), + ); + $this->em->expects($this->once())->method('flush'); + $this->em->expects($this->once())->method('clear'); $this->visitService->{$serviceMethodName}( new class ($isNonLocatableAddress) implements VisitGeolocationHelperInterface { - public function __construct(private bool $isNonLocatableAddress) + public function __construct(private readonly bool $isNonLocatableAddress) { } @@ -138,11 +118,6 @@ class VisitLocatorTest extends TestCase } }, ); - - $findVisits->shouldHaveBeenCalledOnce(); - $persist->shouldHaveBeenCalledTimes($isNonLocatableAddress ? 1 : 0); - $flush->shouldHaveBeenCalledOnce(); - $clear->shouldHaveBeenCalledOnce(); } public function provideIsNonLocatableAddress(): iterable @@ -162,9 +137,4 @@ class VisitLocatorTest extends TestCase yield 'locateAllVisits - locatable address' => ['locateAllVisits', 'findAllVisits', false]; yield 'locateAllVisits - non-locatable address' => ['locateAllVisits', 'findAllVisits', true]; } - - private function mockRepoMethod(string $methodName): MethodProphecy - { - return (new MethodProphecy($this->repo, $methodName, new Argument\ArgumentsWildcard([]))); - } } diff --git a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php index 505a24dd..7d0fb7f1 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Geolocation; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -18,15 +16,13 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; class VisitToLocationHelperTest extends TestCase { - use ProphecyTrait; - private VisitToLocationHelper $helper; - private ObjectProphecy $ipLocationResolver; + private MockObject & IpLocationResolverInterface $ipLocationResolver; protected function setUp(): void { - $this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class); - $this->helper = new VisitToLocationHelper($this->ipLocationResolver->reveal()); + $this->ipLocationResolver = $this->createMock(IpLocationResolverInterface::class); + $this->helper = new VisitToLocationHelper($this->ipLocationResolver); } /** @@ -38,7 +34,7 @@ class VisitToLocationHelperTest extends TestCase IpCannotBeLocatedException $expectedException, ): void { $this->expectExceptionObject($expectedException); - $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotBeCalled(); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); $this->helper->resolveVisitLocation($visit); } @@ -58,8 +54,7 @@ class VisitToLocationHelperTest extends TestCase $e = new WrongIpException(''); $this->expectExceptionObject(IpCannotBeLocatedException::forError($e)); - $this->ipLocationResolver->resolveIpLocation(Argument::cetera())->willThrow($e) - ->shouldBeCalledOnce(); + $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->willThrowException($e); $this->helper->resolveVisitLocation(Visit::forBasePath(new Visitor('foo', 'bar', '1.2.3.4', ''))); } diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php index 0787cbba..ec256eeb 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; @@ -18,37 +17,36 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class NonOrphanVisitsPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - private NonOrphanVisitsPaginatorAdapter $adapter; - private ObjectProphecy $repo; + private MockObject & VisitRepositoryInterface $repo; private VisitsParams $params; private ApiKey $apiKey; protected function setUp(): void { - $this->repo = $this->prophesize(VisitRepositoryInterface::class); + $this->repo = $this->createMock(VisitRepositoryInterface::class); $this->params = VisitsParams::fromRawData([]); $this->apiKey = ApiKey::create(); - $this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params, $this->apiKey); + $this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); } /** @test */ public function countDelegatesToRepository(): void { $expectedCount = 5; - $repoCount = $this->repo->countNonOrphanVisits( + $this->repo->expects($this->once())->method('countNonOrphanVisits')->with( new VisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); self::assertEquals($expectedCount, $result); - $repoCount->shouldHaveBeenCalledOnce(); } /** + * @param int<0, max> $limit + * @param int<0, max> $offset * @test * @dataProvider provideLimitAndOffset */ @@ -56,7 +54,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $repoFind = $this->repo->findNonOrphanVisits(new VisitsListFiltering( + $this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new VisitsListFiltering( $this->params->dateRange, $this->params->excludeBots, $this->apiKey, @@ -67,7 +65,6 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase $result = $this->adapter->getSlice($offset, $limit); self::assertEquals($list, $result); - $repoFind->shouldHaveBeenCalledOnce(); } public function provideLimitAndOffset(): iterable diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 9e85b5fa..6b91a20b 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; @@ -17,34 +16,33 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; class OrphanVisitsPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - private OrphanVisitsPaginatorAdapter $adapter; - private ObjectProphecy $repo; + private MockObject & VisitRepositoryInterface $repo; private VisitsParams $params; protected function setUp(): void { - $this->repo = $this->prophesize(VisitRepositoryInterface::class); + $this->repo = $this->createMock(VisitRepositoryInterface::class); $this->params = VisitsParams::fromRawData([]); - $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params); + $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params); } /** @test */ public function countDelegatesToRepository(): void { $expectedCount = 5; - $repoCount = $this->repo->countOrphanVisits( + $this->repo->expects($this->once())->method('countOrphanVisits')->with( new VisitsCountFiltering($this->params->dateRange), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); self::assertEquals($expectedCount, $result); - $repoCount->shouldHaveBeenCalledOnce(); } /** + * @param int<0, max> $limit + * @param int<0, max> $offset * @test * @dataProvider provideLimitAndOffset */ @@ -52,14 +50,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $repoFind = $this->repo->findOrphanVisits( + $this->repo->expects($this->once())->method('findOrphanVisits')->with( new VisitsListFiltering($this->params->dateRange, $this->params->excludeBots, null, $limit, $offset), )->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); self::assertEquals($list, $result); - $repoFind->shouldHaveBeenCalledOnce(); } public function provideLimitAndOffset(): iterable diff --git a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index 91f2fc32..8ebf1afc 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; @@ -18,13 +17,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlVisitsPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $repo; + private MockObject & VisitRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->prophesize(VisitRepositoryInterface::class); + $this->repo = $this->createMock(VisitRepositoryInterface::class); } /** @test */ @@ -34,7 +31,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $limit = 1; $offset = 5; $adapter = $this->createAdapter(null); - $findVisits = $this->repo->findVisitsByShortCode( + $this->repo->expects($this->exactly($count))->method('findVisitsByShortCode')->with( ShortUrlIdentifier::fromShortCodeAndDomain(''), new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); @@ -42,8 +39,6 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase for ($i = 0; $i < $count; $i++) { $adapter->getSlice($offset, $limit); } - - $findVisits->shouldHaveBeenCalledTimes($count); } /** @test */ @@ -52,7 +47,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $count = 3; $apiKey = ApiKey::create(); $adapter = $this->createAdapter($apiKey); - $countVisits = $this->repo->countVisitsByShortCode( + $this->repo->expects($this->once())->method('countVisitsByShortCode')->with( ShortUrlIdentifier::fromShortCodeAndDomain(''), new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); @@ -60,14 +55,12 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase for ($i = 0; $i < $count; $i++) { $adapter->getNbResults(); } - - $countVisits->shouldHaveBeenCalledOnce(); } private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter { return new ShortUrlVisitsPaginatorAdapter( - $this->repo->reveal(), + $this->repo, ShortUrlIdentifier::fromShortCodeAndDomain(''), VisitsParams::fromRawData([]), $apiKey, diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 59743637..a9aec03f 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter; @@ -17,13 +16,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsForTagPaginatorAdapterTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $repo; + private MockObject & VisitRepositoryInterface $repo; protected function setUp(): void { - $this->repo = $this->prophesize(VisitRepositoryInterface::class); + $this->repo = $this->createMock(VisitRepositoryInterface::class); } /** @test */ @@ -33,7 +30,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $limit = 1; $offset = 5; $adapter = $this->createAdapter(null); - $findVisits = $this->repo->findVisitsByTag( + $this->repo->expects($this->exactly($count))->method('findVisitsByTag')->with( 'foo', new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); @@ -41,8 +38,6 @@ class VisitsForTagPaginatorAdapterTest extends TestCase for ($i = 0; $i < $count; $i++) { $adapter->getSlice($offset, $limit); } - - $findVisits->shouldHaveBeenCalledTimes($count); } /** @test */ @@ -51,7 +46,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $count = 3; $apiKey = ApiKey::create(); $adapter = $this->createAdapter($apiKey); - $countVisits = $this->repo->countVisitsByTag( + $this->repo->expects($this->once())->method('countVisitsByTag')->with( 'foo', new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); @@ -59,17 +54,10 @@ class VisitsForTagPaginatorAdapterTest extends TestCase for ($i = 0; $i < $count; $i++) { $adapter->getNbResults(); } - - $countVisits->shouldHaveBeenCalledOnce(); } private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter { - return new TagVisitsPaginatorAdapter( - $this->repo->reveal(), - 'foo', - VisitsParams::fromRawData([]), - $apiKey, - ); + return new TagVisitsPaginatorAdapter($this->repo, 'foo', VisitsParams::fromRawData([]), $apiKey); } } diff --git a/module/Core/test/Visit/RequestTrackerTest.php b/module/Core/test/Visit/RequestTrackerTest.php index 81b70151..0e1705ee 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Core\Visit; use Fig\Http\Message\RequestMethodInterface; use Laminas\Diactoros\ServerRequestFactory; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; @@ -22,31 +20,28 @@ use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; class RequestTrackerTest extends TestCase { - use ProphecyTrait; - private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; private RequestTracker $requestTracker; - private ObjectProphecy $visitsTracker; - private ObjectProphecy $notFoundType; + private MockObject & VisitsTrackerInterface $visitsTracker; + private MockObject & NotFoundType $notFoundType; private ServerRequestInterface $request; protected function setUp(): void { - $this->notFoundType = $this->prophesize(NotFoundType::class); - $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class); - + $this->visitsTracker = $this->createMock(VisitsTrackerInterface::class); $this->requestTracker = new RequestTracker( - $this->visitsTracker->reveal(), + $this->visitsTracker, new TrackingOptions( disableTrackParam: 'foobar', disableTrackingFrom: ['80.90.100.110', '192.168.10.0/24', '1.2.*.*'], ), ); + $this->notFoundType = $this->createMock(NotFoundType::class); $this->request = ServerRequestFactory::fromGlobals()->withAttribute( NotFoundType::class, - $this->notFoundType->reveal(), + $this->notFoundType, ); } @@ -56,11 +51,10 @@ class RequestTrackerTest extends TestCase */ public function trackingIsDisabledWhenRequestDoesNotMeetConditions(ServerRequestInterface $request): void { + $this->visitsTracker->expects($this->never())->method('track'); + $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); - $this->requestTracker->trackIfApplicable($shortUrl, $request); - - $this->visitsTracker->track(Argument::cetera())->shouldNotHaveBeenCalled(); } public function provideNonTrackingRequests(): iterable @@ -91,61 +85,57 @@ class RequestTrackerTest extends TestCase public function trackingHappensOverShortUrlsWhenRequestMeetsConditions(): void { $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + $this->visitsTracker->expects($this->once())->method('track')->with( + $shortUrl, + $this->isInstanceOf(Visitor::class), + ); $this->requestTracker->trackIfApplicable($shortUrl, $this->request); - - $this->visitsTracker->track($shortUrl, Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); } /** @test */ public function baseUrlErrorIsTracked(): void { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(true); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + $this->notFoundType->expects($this->once())->method('isBaseUrl')->willReturn(true); + $this->notFoundType->expects($this->never())->method('isRegularNotFound'); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); + $this->visitsTracker->expects($this->once())->method('trackBaseUrlVisit')->with( + $this->isInstanceOf(Visitor::class), + ); + $this->visitsTracker->expects($this->never())->method('trackRegularNotFoundVisit'); + $this->visitsTracker->expects($this->never())->method('trackInvalidShortUrlVisit'); $this->requestTracker->trackNotFoundIfApplicable($this->request); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldNotHaveBeenCalled(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); } /** @test */ public function regularNotFoundErrorIsTracked(): void { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(true); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(false); + $this->notFoundType->expects($this->once())->method('isBaseUrl')->willReturn(false); + $this->notFoundType->expects($this->once())->method('isRegularNotFound')->willReturn(true); + $this->notFoundType->expects($this->never())->method('isInvalidShortUrl'); + $this->visitsTracker->expects($this->never())->method('trackBaseUrlVisit'); + $this->visitsTracker->expects($this->once())->method('trackRegularNotFoundVisit')->with( + $this->isInstanceOf(Visitor::class), + ); + $this->visitsTracker->expects($this->never())->method('trackInvalidShortUrlVisit'); $this->requestTracker->trackNotFoundIfApplicable($this->request); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); } /** @test */ public function invalidShortUrlErrorIsTracked(): void { - $isBaseUrl = $this->notFoundType->isBaseUrl()->willReturn(false); - $isRegularNotFound = $this->notFoundType->isRegularNotFound()->willReturn(false); - $isInvalidShortUrl = $this->notFoundType->isInvalidShortUrl()->willReturn(true); + $this->notFoundType->expects($this->once())->method('isBaseUrl')->willReturn(false); + $this->notFoundType->expects($this->once())->method('isRegularNotFound')->willReturn(false); + $this->notFoundType->expects($this->once())->method('isInvalidShortUrl')->willReturn(true); + $this->visitsTracker->expects($this->never())->method('trackBaseUrlVisit'); + $this->visitsTracker->expects($this->never())->method('trackRegularNotFoundVisit'); + $this->visitsTracker->expects($this->once())->method('trackInvalidShortUrlVisit')->with( + $this->isInstanceOf(Visitor::class), + ); $this->requestTracker->trackNotFoundIfApplicable($this->request); - - $isBaseUrl->shouldHaveBeenCalledOnce(); - $isRegularNotFound->shouldHaveBeenCalledOnce(); - $isInvalidShortUrl->shouldHaveBeenCalledOnce(); - $this->visitsTracker->trackBaseUrlVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::type(Visitor::class))->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::type(Visitor::class))->shouldHaveBeenCalledOnce(); } /** @@ -154,10 +144,10 @@ class RequestTrackerTest extends TestCase */ public function notFoundIsNotTrackedIfRequestDoesNotMeetConditions(ServerRequestInterface $request): void { - $this->requestTracker->trackNotFoundIfApplicable($request); + $this->visitsTracker->expects($this->never())->method('trackBaseUrlVisit'); + $this->visitsTracker->expects($this->never())->method('trackRegularNotFoundVisit'); + $this->visitsTracker->expects($this->never())->method('trackInvalidShortUrlVisit'); - $this->visitsTracker->trackBaseUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackRegularNotFoundVisit(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->visitsTracker->trackInvalidShortUrlVisit(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->requestTracker->trackNotFoundIfApplicable($request); } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index c255dd6a..8afd56db 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; use Laminas\Stdlib\ArrayUtils; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; @@ -38,15 +36,14 @@ use function range; class VisitsStatsHelperTest extends TestCase { use ApiKeyHelpersTrait; - use ProphecyTrait; private VisitsStatsHelper $helper; - private ObjectProphecy $em; + private MockObject & EntityManagerInterface $em; protected function setUp(): void { - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->helper = new VisitsStatsHelper($this->em->reveal()); + $this->em = $this->createMock(EntityManagerInterface::class); + $this->helper = new VisitsStatsHelper($this->em); } /** @@ -55,19 +52,18 @@ class VisitsStatsHelperTest extends TestCase */ public function returnsExpectedVisitsStats(int $expectedCount): void { - $repo = $this->prophesize(VisitRepository::class); - $count = $repo->countNonOrphanVisits(new VisitsCountFiltering())->willReturn($expectedCount * 3); - $countOrphan = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn( - $expectedCount, + $repo = $this->createMock(VisitRepository::class); + $repo->expects($this->once())->method('countNonOrphanVisits')->with(new VisitsCountFiltering())->willReturn( + $expectedCount * 3, ); - $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); + $repo->expects($this->once())->method('countOrphanVisits')->with( + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn($expectedCount); + $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); $stats = $this->helper->getVisitsStats(); self::assertEquals(new VisitsStats($expectedCount * 3, $expectedCount), $stats); - $count->shouldHaveBeenCalledOnce(); - $countOrphan->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } public function provideCounts(): iterable @@ -85,22 +81,28 @@ class VisitsStatsHelperTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $spec = $apiKey?->spec(); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($identifier, $spec)->willReturn( - true, - ); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); + $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $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(); + $repo2 = $this->createMock(VisitRepository::class); + $repo2->method('findVisitsByShortCode')->with( + $identifier, + $this->isInstanceOf(VisitsListFiltering::class), + )->willReturn($list); + $repo2->method('countVisitsByShortCode')->with( + $identifier, + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn(1); + + $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ + [ShortUrl::class, $repo], + [Visit::class, $repo2], + ]); $paginator = $this->helper->visitsForShortUrl($identifier, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $count->shouldHaveBeenCalledOnce(); } /** @test */ @@ -109,14 +111,11 @@ class VisitsStatsHelperTest extends TestCase $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $repo = $this->prophesize(ShortUrlRepositoryInterface::class); - $count = $repo->shortCodeIsInUse($identifier, null)->willReturn( - false, - ); - $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce(); + $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, null)->willReturn(false); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); $this->expectException(ShortUrlNotFoundException::class); - $count->shouldBeCalledOnce(); $this->helper->visitsForShortUrl($identifier, new VisitsParams()); } @@ -126,13 +125,11 @@ class VisitsStatsHelperTest extends TestCase { $tag = 'foo'; $apiKey = ApiKey::create(); - $repo = $this->prophesize(TagRepository::class); - $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(false); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $repo = $this->createMock(TagRepository::class); + $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(false); + $this->em->expects($this->once())->method('getRepository')->with(Tag::class)->willReturn($repo); $this->expectException(TagNotFoundException::class); - $tagExists->shouldBeCalledOnce(); - $getRepo->shouldBeCalledOnce(); $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); } @@ -144,21 +141,24 @@ class VisitsStatsHelperTest extends TestCase public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { $tag = 'foo'; - $repo = $this->prophesize(TagRepository::class); - $tagExists = $repo->tagExists($tag, $apiKey)->willReturn(true); - $getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal()); + $repo = $this->createMock(TagRepository::class); + $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $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(); + $repo2 = $this->createMock(VisitRepository::class); + $repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn( + $list, + ); + $repo2->method('countVisitsByTag')->with($tag, $this->isInstanceOf(VisitsCountFiltering::class))->willReturn(1); + + $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ + [Tag::class, $repo], + [Visit::class, $repo2], + ]); $paginator = $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $tagExists->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @test */ @@ -166,13 +166,11 @@ class VisitsStatsHelperTest extends TestCase { $domain = 'foo.com'; $apiKey = ApiKey::create(); - $repo = $this->prophesize(DomainRepository::class); - $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(false); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $repo = $this->createMock(DomainRepository::class); + $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(false); + $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->expectException(DomainNotFoundException::class); - $domainExists->shouldBeCalledOnce(); - $getRepo->shouldBeCalledOnce(); $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); } @@ -184,21 +182,28 @@ class VisitsStatsHelperTest extends TestCase public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { $domain = 'foo.com'; - $repo = $this->prophesize(DomainRepository::class); - $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(true); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $repo = $this->createMock(DomainRepository::class); + $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByDomain($domain, Argument::type(VisitsListFiltering::class))->willReturn($list); - $repo2->countVisitsByDomain($domain, Argument::type(VisitsCountFiltering::class))->willReturn(1); - $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + $repo2 = $this->createMock(VisitRepository::class); + $repo2->method('findVisitsByDomain')->with( + $domain, + $this->isInstanceOf(VisitsListFiltering::class), + )->willReturn($list); + $repo2->method('countVisitsByDomain')->with( + $domain, + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn(1); + + $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ + [Domain::class, $repo], + [Visit::class, $repo2], + ]); $paginator = $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $domainExists->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @@ -207,56 +212,63 @@ class VisitsStatsHelperTest extends TestCase */ public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { - $repo = $this->prophesize(DomainRepository::class); - $domainExists = $repo->domainExists(Argument::cetera()); - $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $repo = $this->createMock(DomainRepository::class); + $repo->expects($this->never())->method('domainExists'); $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo2 = $this->prophesize(VisitRepository::class); - $repo2->findVisitsByDomain('DEFAULT', Argument::type(VisitsListFiltering::class))->willReturn($list); - $repo2->countVisitsByDomain('DEFAULT', Argument::type(VisitsCountFiltering::class))->willReturn(1); - $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + $repo2 = $this->createMock(VisitRepository::class); + $repo2->method('findVisitsByDomain')->with( + 'DEFAULT', + $this->isInstanceOf(VisitsListFiltering::class), + )->willReturn($list); + $repo2->method('countVisitsByDomain')->with( + 'DEFAULT', + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn(1); + + $this->em->expects($this->exactly(2))->method('getRepository')->willReturnMap([ + [Domain::class, $repo], + [Visit::class, $repo2], + ]); $paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $domainExists->shouldNotHaveBeenCalled(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @test */ public function orphanVisitsAreReturnedAsExpected(): void { $list = map(range(0, 3), fn () => Visit::forBasePath(Visitor::emptyInstance())); - $repo = $this->prophesize(VisitRepository::class); - $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()); + $repo = $this->createMock(VisitRepository::class); + $repo->expects($this->once())->method('countOrphanVisits')->with( + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn(count($list)); + $repo->expects($this->once())->method('findOrphanVisits')->with( + $this->isInstanceOf(VisitsListFiltering::class), + )->willReturn($list); + $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); $paginator = $this->helper->orphanVisits(new VisitsParams()); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $listVisits->shouldHaveBeenCalledOnce(); - $countVisits->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } /** @test */ public function nonOrphanVisitsAreReturnedAsExpected(): void { $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); - $repo = $this->prophesize(VisitRepository::class); - $countVisits = $repo->countNonOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn( - count($list), - ); - $listVisits = $repo->findNonOrphanVisits(Argument::type(VisitsListFiltering::class))->willReturn($list); - $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal()); + $repo = $this->createMock(VisitRepository::class); + $repo->expects($this->once())->method('countNonOrphanVisits')->with( + $this->isInstanceOf(VisitsCountFiltering::class), + )->willReturn(count($list)); + $repo->expects($this->once())->method('findNonOrphanVisits')->with( + $this->isInstanceOf(VisitsListFiltering::class), + )->willReturn($list); + $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); $paginator = $this->helper->nonOrphanVisits(new VisitsParams()); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); - $listVisits->shouldHaveBeenCalledOnce(); - $countVisits->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index c10d57b1..d981f755 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Visit; use Doctrine\ORM\EntityManager; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Options\TrackingOptions; @@ -19,16 +17,13 @@ use Shlinkio\Shlink\Core\Visit\VisitsTracker; class VisitsTrackerTest extends TestCase { - use ProphecyTrait; - - private VisitsTracker $visitsTracker; - private ObjectProphecy $em; - private ObjectProphecy $eventDispatcher; + private MockObject & EntityManager $em; + private MockObject & EventDispatcherInterface $eventDispatcher; protected function setUp(): void { - $this->em = $this->prophesize(EntityManager::class); - $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->em = $this->createMock(EntityManager::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); } /** @@ -37,14 +32,15 @@ class VisitsTrackerTest extends TestCase */ public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): void { - $persist = $this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->will(function (): void { - }); + $this->em->expects($this->once())->method('persist')->with( + $this->callback(fn (Visit $visit) => $visit->setId('1') !== null), + ); + $this->em->expects($this->once())->method('flush'); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with( + $this->isInstanceOf(UrlVisited::class), + ); $this->visitsTracker()->{$method}(...$args); - - $persist->shouldHaveBeenCalledOnce(); - $this->em->flush()->shouldHaveBeenCalledOnce(); - $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); } /** @@ -53,11 +49,11 @@ class VisitsTrackerTest extends TestCase */ public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void { - $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->never())->method('flush'); + $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->em->flush()->shouldNotHaveBeenCalled(); + $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); } public function provideTrackingMethodNames(): iterable @@ -74,11 +70,11 @@ class VisitsTrackerTest extends TestCase */ public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void { - $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::emptyInstance()); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->never())->method('flush'); + $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->em->flush()->shouldNotHaveBeenCalled(); + $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::emptyInstance()); } public function provideOrphanTrackingMethodNames(): iterable @@ -90,10 +86,6 @@ class VisitsTrackerTest extends TestCase private function visitsTracker(?TrackingOptions $options = null): VisitsTracker { - return new VisitsTracker( - $this->em->reveal(), - $this->eventDispatcher->reveal(), - $options ?? new TrackingOptions(), - ); + return new VisitsTracker($this->em, $this->eventDispatcher, $options ?? new TrackingOptions()); } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index ce917b7b..cf394740 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -90,7 +90,10 @@ return [ Visit\Transformer\OrphanVisitDataTransformer::class, ], Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class], - Action\ShortUrl\ListShortUrlsAction::class => [ShortUrl\ShortUrlService::class, ShortUrlDataTransformer::class], + Action\ShortUrl\ListShortUrlsAction::class => [ + ShortUrl\ShortUrlListService::class, + ShortUrlDataTransformer::class, + ], Action\Tag\ListTagsAction::class => [TagService::class], Action\Tag\TagsStatsAction::class => [TagService::class], Action\Tag\DeleteTagsAction::class => [TagService::class], diff --git a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php index 1f915ea1..8ca247a7 100644 --- a/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php +++ b/module/Rest/src/Action/ShortUrl/ListShortUrlsAction.php @@ -10,7 +10,7 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -22,8 +22,8 @@ class ListShortUrlsAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct( - private ShortUrlServiceInterface $shortUrlService, - private DataTransformerInterface $transformer, + private readonly ShortUrlListServiceInterface $shortUrlService, + private readonly DataTransformerInterface $transformer, ) { } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 64803969..5a4edb81 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -2,8 +2,6 @@ declare(strict_types=1); -// phpcs:disable -// TODO Enable coding style checks again once code sniffer 3.7 is released https://github.com/squizlabs/PHP_CodeSniffer/issues/3474 namespace Shlinkio\Shlink\Rest\ApiKey; use Happyr\DoctrineSpecification\Spec; @@ -19,6 +17,22 @@ enum Role: string case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; + public function toFriendlyName(): string + { + return match ($this) { + self::AUTHORED_SHORT_URLS => 'Author only', + self::DOMAIN_SPECIFIC => 'Domain only', + }; + } + + public function paramName(): string + { + return match ($this) { + self::AUTHORED_SHORT_URLS => 'author-only', + self::DOMAIN_SPECIFIC => 'domain-only', + }; + } + public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { return match ($role->role()) { @@ -44,12 +58,4 @@ enum Role: string { return $meta['authority'] ?? ''; } - - public static function toFriendlyName(Role $role): string - { - return match ($role) { - self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', - }; - } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 26d271f0..889b67af 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -363,7 +363,7 @@ class CreateShortUrlTest extends ApiTestCase } /** - * @return array{int $statusCode, array $payload} + * @return array{int, array} */ private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key', string $version = '2'): array { diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 92f9bbe0..fefbdcba 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -62,13 +62,13 @@ class EditShortUrlTest extends ApiTestCase ]]; } - private function findShortUrlMetaByShortCode(string $shortCode): ?array + private function findShortUrlMetaByShortCode(string $shortCode): array { $matchingShortUrl = $this->getJsonResponsePayload( $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode), ); - return $matchingShortUrl['meta'] ?? null; + return $matchingShortUrl['meta'] ?? []; } /** diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index b28a0b5d..d3a515c1 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -18,11 +18,16 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://shlink.io', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 3, + 'visitsSummary' => [ + 'total' => 3, + 'nonBots' => 3, + 'bots' => 0, + ], 'tags' => ['foo'], 'meta' => [ 'validSince' => null, 'validUntil' => null, - 'maxVisits' => null, + 'maxVisits' => 2, ], 'domain' => null, 'title' => 'My cool title', @@ -35,10 +40,15 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://shlink.io/documentation/', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 2, + 'bots' => 0, + ], 'tags' => [], 'meta' => [ 'validSince' => null, - 'validUntil' => null, + 'validUntil' => '2020-05-01T00:00:00+00:00', 'maxVisits' => null, ], 'domain' => null, @@ -52,6 +62,11 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://google.com', 'dateCreated' => '2018-10-20T00:00:00+00:00', 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], 'tags' => [], 'meta' => [ 'validSince' => null, @@ -71,6 +86,11 @@ class ListShortUrlsTest extends ApiTestCase . '/acmailer-7-0-the-most-important-release-in-a-long-time/', 'dateCreated' => '2019-01-01T00:00:10+00:00', 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], 'tags' => ['bar', 'foo'], 'meta' => [ 'validSince' => '2020-05-01T00:00:00+00:00', @@ -88,6 +108,11 @@ class ListShortUrlsTest extends ApiTestCase 'longUrl' => 'https://shlink.io', 'dateCreated' => '2019-01-01T00:00:20+00:00', 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], 'tags' => [], 'meta' => [ 'validSince' => null, @@ -107,6 +132,11 @@ class ListShortUrlsTest extends ApiTestCase . '/considerations-to-properly-use-open-source-software-projects/', 'dateCreated' => '2019-01-01T00:00:30+00:00', 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], 'tags' => ['foo'], 'meta' => [ 'validSince' => null, @@ -147,6 +177,20 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_DOCS, ], 'valid_api_key']; + yield [['excludePastValidUntil' => 'true'], [ + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, + ], 'valid_api_key']; + yield [['excludeMaxVisitsReached' => 'true'], [ + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_DOCS, + ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ self::SHORT_URL_SHLINK_WITH_TITLE, self::SHORT_URL_CUSTOM_SLUG, diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index a159737e..9a876463 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -23,25 +23,26 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf public function load(ObjectManager $manager): void { - $relationResolver = new PersistenceShortUrlRelationResolver($manager); + $relationResolver = new PersistenceShortUrlRelationResolver($manager); // @phpstan-ignore-line /** @var ApiKey $authorApiKey */ $authorApiKey = $this->getReference('author_api_key'); $abcShortUrl = $this->setShortUrlDate( - ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'abc123', 'apiKey' => $authorApiKey, 'longUrl' => 'https://shlink.io', 'tags' => ['foo'], 'title' => 'My cool title', 'crawlable' => true, + 'maxVisits' => 2, ]), $relationResolver), '2018-05-01', ); $manager->persist($abcShortUrl); - $defShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $defShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([ 'validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456', 'apiKey' => $authorApiKey, @@ -51,7 +52,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); - $customShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $customShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'custom', 'maxVisits' => 2, 'apiKey' => $authorApiKey, @@ -61,14 +62,16 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf $manager->persist($customShortUrl); $ghiShortUrl = $this->setShortUrlDate( - ShortUrl::fromMeta(ShortUrlCreation::fromRawData( - ['customSlug' => 'ghi789', 'longUrl' => 'https://shlink.io/documentation/'], - )), + ShortUrl::create(ShortUrlCreation::fromRawData([ + 'customSlug' => 'ghi789', + 'longUrl' => 'https://shlink.io/documentation/', + 'validUntil' => Chronos::parse('2020-05-01'), // In the past + ])), '2018-05-01', ); $manager->persist($ghiShortUrl); - $withDomainDuplicatingShortCode = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlCreation::fromRawData([ + $withDomainDuplicatingShortCode = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData([ 'domain' => 'example.com', 'customSlug' => 'ghi789', 'longUrl' => 'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-' @@ -77,7 +80,7 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf ]), $relationResolver), '2019-01-01 00:00:30'); $manager->persist($withDomainDuplicatingShortCode); - $withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::fromMeta(ShortUrlCreation::fromRawData( + $withDomainAndSlugShortUrl = $this->setShortUrlDate(ShortUrl::create(ShortUrlCreation::fromRawData( ['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain', 'longUrl' => 'https://google.com'], )), '2018-10-20'); $manager->persist($withDomainAndSlugShortUrl); diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php index 19e34ccf..5ff409a0 100644 --- a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Domain; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; @@ -22,15 +20,13 @@ use function array_key_exists; class DomainRedirectsActionTest extends TestCase { - use ProphecyTrait; - private DomainRedirectsAction $action; - private ObjectProphecy $domainService; + private MockObject & DomainServiceInterface $domainService; protected function setUp(): void { - $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->action = new DomainRedirectsAction($this->domainService->reveal()); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->action = new DomainRedirectsAction($this->domainService); } /** @@ -42,8 +38,8 @@ class DomainRedirectsActionTest extends TestCase $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); $this->expectException(ValidationException::class); - $this->domainService->getOrCreate(Argument::cetera())->shouldNotBeCalled(); - $this->domainService->configureNotFoundRedirects(Argument::cetera())->shouldNotBeCalled(); + $this->domainService->expects($this->never())->method('getOrCreate'); + $this->domainService->expects($this->never())->method('configureNotFoundRedirects'); $this->action->handle($request); } @@ -70,19 +66,19 @@ class DomainRedirectsActionTest extends TestCase $request = ServerRequestFactory::fromGlobals()->withParsedBody($redirects) ->withAttribute(ApiKey::class, $apiKey); - $getOrCreate = $this->domainService->getOrCreate($authority)->willReturn($domain); - $configureNotFoundRedirects = $this->domainService->configureNotFoundRedirects( + $this->domainService->expects($this->once())->method('getOrCreate')->with($authority)->willReturn($domain); + $this->domainService->expects($this->once())->method('configureNotFoundRedirects')->with( $authority, NotFoundRedirects::withRedirects( array_key_exists(DomainRedirectsInputFilter::BASE_URL_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::BASE_URL_REDIRECT] - : $domain?->baseUrlRedirect(), + : $domain->baseUrlRedirect(), array_key_exists(DomainRedirectsInputFilter::REGULAR_404_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::REGULAR_404_REDIRECT] - : $domain?->regular404Redirect(), + : $domain->regular404Redirect(), array_key_exists(DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT, $redirects) ? $redirects[DomainRedirectsInputFilter::INVALID_SHORT_URL_REDIRECT] - : $domain?->invalidShortUrlRedirect(), + : $domain->invalidShortUrlRedirect(), ), $apiKey, ); @@ -93,8 +89,6 @@ class DomainRedirectsActionTest extends TestCase $payload = $response->getPayload(); self::assertEquals($expectedResult, $payload->jsonSerialize()); - $getOrCreate->shouldHaveBeenCalledOnce(); - $configureNotFoundRedirects->shouldHaveBeenCalledOnce(); } public function provideDomainsAndRedirects(): iterable diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php index b04c8d0e..ac31cd0e 100644 --- a/module/Rest/test/Action/Domain/ListDomainsActionTest.php +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Domain; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Domain\Entity\Domain; @@ -19,17 +18,15 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListDomainsActionTest extends TestCase { - use ProphecyTrait; - private ListDomainsAction $action; - private ObjectProphecy $domainService; + private MockObject & DomainServiceInterface $domainService; private NotFoundRedirectOptions $options; protected function setUp(): void { - $this->domainService = $this->prophesize(DomainServiceInterface::class); + $this->domainService = $this->createMock(DomainServiceInterface::class); $this->options = new NotFoundRedirectOptions(); - $this->action = new ListDomainsAction($this->domainService->reveal(), $this->options); + $this->action = new ListDomainsAction($this->domainService, $this->options); } /** @test */ @@ -40,7 +37,7 @@ class ListDomainsActionTest extends TestCase DomainItem::forDefaultDomain('bar.com', new NotFoundRedirectOptions()), DomainItem::forNonDefaultDomain(Domain::withAuthority('baz.com')), ]; - $listDomains = $this->domainService->listDomains($apiKey)->willReturn($domains); + $this->domainService->expects($this->once())->method('listDomains')->with($apiKey)->willReturn($domains); /** @var JsonResponse $resp */ $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); @@ -52,6 +49,5 @@ class ListDomainsActionTest extends TestCase 'defaultRedirects' => NotFoundRedirects::fromConfig($this->options), ], ], $payload); - $listDomains->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index 461152a4..4ce00578 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -11,37 +11,34 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Rest\Action\HealthAction; class HealthActionTest extends TestCase { - use ProphecyTrait; - private HealthAction $action; - private ObjectProphecy $conn; + private MockObject & Connection $conn; protected function setUp(): void { - $this->conn = $this->prophesize(Connection::class); - $this->conn->executeQuery(Argument::cetera())->willReturn($this->prophesize(Result::class)->reveal()); - $dbPlatform = $this->prophesize(AbstractPlatform::class); - $dbPlatform->getDummySelectSQL()->willReturn(''); - $this->conn->getDatabasePlatform()->willReturn($dbPlatform->reveal()); + $this->conn = $this->createMock(Connection::class); + $dbPlatform = $this->createMock(AbstractPlatform::class); + $dbPlatform->method('getDummySelectSQL')->willReturn(''); + $this->conn->method('getDatabasePlatform')->willReturn($dbPlatform); - $em = $this->prophesize(EntityManagerInterface::class); - $em->getConnection()->willReturn($this->conn->reveal()); + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getConnection')->willReturn($this->conn); - $this->action = new HealthAction($em->reveal(), new AppOptions(version: '1.2.3')); + $this->action = new HealthAction($em, new AppOptions(version: '1.2.3')); } /** @test */ public function passResponseIsReturnedWhenDummyQuerySucceeds(): void { + $this->conn->expects($this->once())->method('executeQuery')->willReturn($this->createMock(Result::class)); + /** @var JsonResponse $resp */ $resp = $this->action->handle(new ServerRequest()); $payload = $resp->getPayload(); @@ -54,13 +51,12 @@ class HealthActionTest extends TestCase 'project' => 'https://github.com/shlinkio/shlink', ], $payload['links']); self::assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); - $this->conn->executeQuery(Argument::cetera())->shouldHaveBeenCalledOnce(); } /** @test */ public function failResponseIsReturnedWhenDummyQueryThrowsException(): void { - $executeQuery = $this->conn->executeQuery(Argument::cetera())->willThrow(Exception::class); + $this->conn->expects($this->once())->method('executeQuery')->willThrowException(new Exception()); /** @var JsonResponse $resp */ $resp = $this->action->handle(new ServerRequest()); @@ -74,6 +70,5 @@ class HealthActionTest extends TestCase 'project' => 'https://github.com/shlinkio/shlink', ], $payload['links']); self::assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); - $executeQuery->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index e586a641..ada836c1 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -7,23 +7,19 @@ namespace ShlinkioTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Action\MercureInfoAction; use Shlinkio\Shlink\Rest\Exception\MercureException; class MercureInfoActionTest extends TestCase { - use ProphecyTrait; - - private ObjectProphecy $provider; + private MockObject & JwtProviderInterface $provider; protected function setUp(): void { - $this->provider = $this->prophesize(JwtProviderInterface::class); + $this->provider = $this->createMock(JwtProviderInterface::class); } /** @@ -32,12 +28,11 @@ class MercureInfoActionTest extends TestCase */ public function throwsExceptionWhenConfigDoesNotHavePublicHost(array $mercureConfig): void { - $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123'); + $this->provider->expects($this->never())->method('buildSubscriptionToken'); - $action = new MercureInfoAction($this->provider->reveal(), $mercureConfig); + $action = new MercureInfoAction($this->provider, $mercureConfig); $this->expectException(MercureException::class); - $buildToken->shouldNotBeCalled(); $action->handle(ServerRequestFactory::fromGlobals()); } @@ -60,9 +55,9 @@ class MercureInfoActionTest extends TestCase */ public function returnsExpectedInfoWhenEverythingIsOk(?int $days): void { - $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123'); + $this->provider->expects($this->once())->method('buildSubscriptionToken')->willReturn('abc.123'); - $action = new MercureInfoAction($this->provider->reveal(), [ + $action = new MercureInfoAction($this->provider, [ 'public_hub_url' => 'http://foobar.com', 'jwt_days_duration' => $days, ]); @@ -79,7 +74,6 @@ class MercureInfoActionTest extends TestCase Chronos::now()->addDays($days ?? 1)->startOfDay(), Chronos::parse($payload['jwtExpiration'])->startOfDay(), ); - $buildToken->shouldHaveBeenCalledOnce(); } public function provideDays(): iterable diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 40284969..246b2edf 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -8,10 +8,8 @@ use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -23,23 +21,16 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class CreateShortUrlActionTest extends TestCase { - use ProphecyTrait; - private CreateShortUrlAction $action; - private ObjectProphecy $urlShortener; - private ObjectProphecy $transformer; + private MockObject & UrlShortener $urlShortener; + private MockObject & DataTransformerInterface $transformer; protected function setUp(): void { - $this->urlShortener = $this->prophesize(UrlShortener::class); - $this->transformer = $this->prophesize(DataTransformerInterface::class); - $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); + $this->urlShortener = $this->createMock(UrlShortener::class); + $this->transformer = $this->createMock(DataTransformerInterface::class); - $this->action = new CreateShortUrlAction( - $this->urlShortener->reveal(), - $this->transformer->reveal(), - new UrlShortenerOptions(), - ); + $this->action = new CreateShortUrlAction($this->urlShortener, $this->transformer, new UrlShortenerOptions()); } /** @test */ @@ -58,8 +49,12 @@ class CreateShortUrlActionTest extends TestCase ]; $expectedMeta['apiKey'] = $apiKey; - $shorten = $this->urlShortener->shorten(ShortUrlCreation::fromRawData($expectedMeta))->willReturn($shortUrl); - $transform = $this->transformer->transform($shortUrl)->willReturn(['shortUrl' => 'stringified_short_url']); + $this->urlShortener->expects($this->once())->method('shorten')->with( + ShortUrlCreation::fromRawData($expectedMeta), + )->willReturn($shortUrl); + $this->transformer->expects($this->once())->method('transform')->with($shortUrl)->willReturn( + ['shortUrl' => 'stringified_short_url'], + ); $request = ServerRequestFactory::fromGlobals()->withParsedBody($body)->withAttribute(ApiKey::class, $apiKey); @@ -69,8 +64,6 @@ class CreateShortUrlActionTest extends TestCase self::assertEquals(200, $response->getStatusCode()); self::assertEquals('stringified_short_url', $payload['shortUrl']); - $shorten->shouldHaveBeenCalledOnce(); - $transform->shouldHaveBeenCalledOnce(); } /** @@ -79,8 +72,8 @@ class CreateShortUrlActionTest extends TestCase */ public function anInvalidDomainReturnsError(string $domain): void { - $shortUrl = ShortUrl::createEmpty(); - $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); + $this->urlShortener->expects($this->never())->method('shorten'); + $this->transformer->expects($this->never())->method('transform'); $request = (new ServerRequest())->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -88,7 +81,6 @@ class CreateShortUrlActionTest extends TestCase ])->withAttribute(ApiKey::class, ApiKey::create()); $this->expectException(ValidationException::class); - $urlToShortCode->shouldNotBeCalled(); $this->action->handle($request); } diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index bc073cf6..d68e3608 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -5,39 +5,31 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\DeleteShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteShortUrlActionTest extends TestCase { - use ProphecyTrait; - private DeleteShortUrlAction $action; - private ObjectProphecy $service; + private MockObject & DeleteShortUrlServiceInterface $service; protected function setUp(): void { - $this->service = $this->prophesize(DeleteShortUrlServiceInterface::class); - $this->action = new DeleteShortUrlAction($this->service->reveal()); + $this->service = $this->createMock(DeleteShortUrlServiceInterface::class); + $this->action = new DeleteShortUrlAction($this->service); } /** @test */ public function emptyResponseIsReturnedIfProperlyDeleted(): void { $apiKey = ApiKey::create(); - $deleteByShortCode = $this->service->deleteByShortCode(Argument::any(), false, $apiKey)->will( - function (): void { - }, - ); + $this->service->expects($this->once())->method('deleteByShortCode'); $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); self::assertEquals(204, $resp->getStatusCode()); - $deleteByShortCode->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index 19cb27e9..dde17ca6 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; @@ -19,15 +17,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class EditShortUrlActionTest extends TestCase { - use ProphecyTrait; - private EditShortUrlAction $action; - private ObjectProphecy $shortUrlService; + private MockObject & ShortUrlServiceInterface $shortUrlService; protected function setUp(): void { - $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class); - $this->action = new EditShortUrlAction($this->shortUrlService->reveal(), new ShortUrlDataTransformer( + $this->shortUrlService = $this->createMock(ShortUrlServiceInterface::class); + $this->action = new EditShortUrlAction($this->shortUrlService, new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); } @@ -38,6 +34,7 @@ class EditShortUrlActionTest extends TestCase $request = (new ServerRequest())->withParsedBody([ 'maxVisits' => 'invalid', ]); + $this->shortUrlService->expects($this->never())->method('updateShortUrl'); $this->expectException(ValidationException::class); @@ -52,13 +49,10 @@ class EditShortUrlActionTest extends TestCase ->withParsedBody([ 'maxVisits' => 5, ]); - $updateMeta = $this->shortUrlService->updateShortUrl(Argument::cetera())->willReturn( - ShortUrl::createEmpty(), - ); + $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(ShortUrl::createEmpty()); $resp = $this->action->handle($request); self::assertEquals(200, $resp->getStatusCode()); - $updateMeta->shouldHaveBeenCalled(); } } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 65e6ebbf..4164e78b 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -8,29 +8,26 @@ use Cake\Chronos\Chronos; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; +use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ListShortUrlsActionTest extends TestCase { - use ProphecyTrait; - private ListShortUrlsAction $action; - private ObjectProphecy $service; + private MockObject & ShortUrlListServiceInterface $service; protected function setUp(): void { - $this->service = $this->prophesize(ShortUrlService::class); + $this->service = $this->createMock(ShortUrlListServiceInterface::class); - $this->action = new ListShortUrlsAction($this->service->reveal(), new ShortUrlDataTransformer( + $this->action = new ListShortUrlsAction($this->service, new ShortUrlDataTransformer( new ShortUrlStringifier([ 'hostname' => 'doma.in', 'schema' => 'https', @@ -54,7 +51,7 @@ class ListShortUrlsActionTest extends TestCase $apiKey = ApiKey::create(); $request = ServerRequestFactory::fromGlobals()->withQueryParams($query) ->withAttribute(ApiKey::class, $apiKey); - $listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([ + $this->service->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => $expectedPage, 'searchTerm' => $expectedSearchTerm, 'tags' => $expectedTags, @@ -71,7 +68,6 @@ class ListShortUrlsActionTest extends TestCase self::assertArrayHasKey('data', $payload['shortUrls']); self::assertEquals([], $payload['shortUrls']['data']); self::assertEquals(200, $response->getStatusCode()); - $listShortUrls->shouldHaveBeenCalledOnce(); } public function provideFilteringData(): iterable diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 1b805c5f..c7d6fd26 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -5,9 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; @@ -18,15 +17,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ResolveShortUrlActionTest extends TestCase { - use ProphecyTrait; - private ResolveShortUrlAction $action; - private ObjectProphecy $urlResolver; + private MockObject & ShortUrlResolverInterface $urlResolver; protected function setUp(): void { - $this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class); - $this->action = new ResolveShortUrlAction($this->urlResolver->reveal(), new ShortUrlDataTransformer( + $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->action = new ResolveShortUrlAction($this->urlResolver, new ShortUrlDataTransformer( new ShortUrlStringifier([]), )); } @@ -36,11 +33,10 @@ class ResolveShortUrlActionTest extends TestCase { $shortCode = 'abc123'; $apiKey = ApiKey::create(); - $this->urlResolver->resolveShortUrl( + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), $apiKey, - )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) - ->shouldBeCalledOnce(); + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 3fd29c2f..14848696 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -19,21 +17,18 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class SingleStepCreateShortUrlActionTest extends TestCase { - use ProphecyTrait; - private SingleStepCreateShortUrlAction $action; - private ObjectProphecy $urlShortener; - private ObjectProphecy $transformer; + private MockObject & UrlShortenerInterface $urlShortener; protected function setUp(): void { - $this->urlShortener = $this->prophesize(UrlShortenerInterface::class); - $this->transformer = $this->prophesize(DataTransformerInterface::class); - $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); + $this->urlShortener = $this->createMock(UrlShortenerInterface::class); + $transformer = $this->createMock(DataTransformerInterface::class); + $transformer->method('transform')->willReturn([]); $this->action = new SingleStepCreateShortUrlAction( - $this->urlShortener->reveal(), - $this->transformer->reveal(), + $this->urlShortener, + $transformer, new UrlShortenerOptions(), ); } @@ -46,13 +41,12 @@ class SingleStepCreateShortUrlActionTest extends TestCase $request = (new ServerRequest())->withQueryParams([ 'longUrl' => 'http://foobar.com', ])->withAttribute(ApiKey::class, $apiKey); - $generateShortCode = $this->urlShortener->shorten( + $this->urlShortener->expects($this->once())->method('shorten')->with( ShortUrlCreation::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']), )->willReturn(ShortUrl::createEmpty()); $resp = $this->action->handle($request); self::assertEquals(200, $resp->getStatusCode()); - $generateShortCode->shouldHaveBeenCalled(); } } diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 457507e8..63d30c4b 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -5,25 +5,21 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; class DeleteTagsActionTest extends TestCase { - use ProphecyTrait; - private DeleteTagsAction $action; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->action = new DeleteTagsAction($this->tagService->reveal()); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->action = new DeleteTagsAction($this->tagService); } /** @@ -35,12 +31,14 @@ class DeleteTagsActionTest extends TestCase $request = (new ServerRequest()) ->withQueryParams(['tags' => $tags]) ->withAttribute(ApiKey::class, ApiKey::create()); - $deleteTags = $this->tagService->deleteTags($tags ?: [], Argument::type(ApiKey::class)); + $this->tagService->expects($this->once())->method('deleteTags')->with( + $tags ?? [], + $this->isInstanceOf(ApiKey::class), + ); $response = $this->action->handle($request); self::assertEquals(204, $response->getStatusCode()); - $deleteTags->shouldHaveBeenCalled(); } public function provideTags(): iterable diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index e362aca9..dc4c2c06 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\Entity\Tag; @@ -23,15 +21,13 @@ use function count; class ListTagsActionTest extends TestCase { - use ProphecyTrait; - private ListTagsAction $action; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->action = new ListTagsAction($this->tagService->reveal()); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->action = new ListTagsAction($this->tagService); } /** @@ -42,9 +38,10 @@ class ListTagsActionTest extends TestCase { $tags = [new Tag('foo'), new Tag('bar')]; $tagsCount = count($tags); - $listTags = $this->tagService->listTags(Argument::any(), Argument::type(ApiKey::class))->willReturn( - new Paginator(new ArrayAdapter($tags)), - ); + $this->tagService->expects($this->once())->method('listTags')->with( + $this->anything(), + $this->isInstanceOf(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter($tags))); /** @var JsonResponse $resp */ $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); @@ -62,7 +59,6 @@ class ListTagsActionTest extends TestCase ], ], ], $payload); - $listTags->shouldHaveBeenCalled(); } public function provideNoStatsQueries(): iterable @@ -80,9 +76,10 @@ class ListTagsActionTest extends TestCase new TagInfo('bar', 3, 10), ]; $itemsCount = count($stats); - $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn( - new Paginator(new ArrayAdapter($stats)), - ); + $this->tagService->expects($this->once())->method('tagsInfo')->with( + $this->anything(), + $this->isInstanceOf(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter($stats))); $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); /** @var JsonResponse $resp */ @@ -102,7 +99,6 @@ class ListTagsActionTest extends TestCase ], ], ], $payload); - $tagsInfo->shouldHaveBeenCalled(); } private function requestWithApiKey(): ServerRequestInterface diff --git a/module/Rest/test/Action/Tag/TagsStatsActionTest.php b/module/Rest/test/Action/Tag/TagsStatsActionTest.php index 44e6afb0..0694e7bf 100644 --- a/module/Rest/test/Action/Tag/TagsStatsActionTest.php +++ b/module/Rest/test/Action/Tag/TagsStatsActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; @@ -22,15 +20,13 @@ use function count; class TagsStatsActionTest extends TestCase { - use ProphecyTrait; - private TagsStatsAction $action; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->action = new TagsStatsAction($this->tagService->reveal()); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->action = new TagsStatsAction($this->tagService); } /** @test */ @@ -41,9 +37,10 @@ class TagsStatsActionTest extends TestCase new TagInfo('bar', 3, 10), ]; $itemsCount = count($stats); - $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn( - new Paginator(new ArrayAdapter($stats)), - ); + $this->tagService->expects($this->once())->method('tagsInfo')->with( + $this->anything(), + $this->isInstanceOf(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter($stats))); $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']); /** @var JsonResponse $resp */ @@ -62,7 +59,6 @@ class TagsStatsActionTest extends TestCase ], ], ], $payload); - $tagsInfo->shouldHaveBeenCalled(); } private function requestWithApiKey(): ServerRequestInterface diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index aea611ac..d08d00e2 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -5,10 +5,8 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Tag\Entity\Tag; @@ -19,15 +17,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class UpdateTagActionTest extends TestCase { - use ProphecyTrait; - private UpdateTagAction $action; - private ObjectProphecy $tagService; + private MockObject & TagServiceInterface $tagService; protected function setUp(): void { - $this->tagService = $this->prophesize(TagServiceInterface::class); - $this->action = new UpdateTagAction($this->tagService->reveal()); + $this->tagService = $this->createMock(TagServiceInterface::class); + $this->action = new UpdateTagAction($this->tagService); } /** @@ -57,15 +53,14 @@ class UpdateTagActionTest extends TestCase 'oldName' => 'foo', 'newName' => 'bar', ]); - $rename = $this->tagService->renameTag( + $this->tagService->expects($this->once())->method('renameTag')->with( TagRenaming::fromNames('foo', 'bar'), - Argument::type(ApiKey::class), + $this->isInstanceOf(ApiKey::class), )->willReturn(new Tag('bar')); $resp = $this->action->handle($request); self::assertEquals(204, $resp->getStatusCode()); - $rename->shouldHaveBeenCalled(); } private function requestWithApiKey(): ServerRequestInterface diff --git a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php index 6e3f2fe8..e4b714e4 100644 --- a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -18,15 +16,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class DomainVisitsActionTest extends TestCase { - use ProphecyTrait; - private DomainVisitsAction $action; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->action = new DomainVisitsAction($this->visitsHelper->reveal(), 'the_default.com'); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->action = new DomainVisitsAction($this->visitsHelper, 'the_default.com'); } /** @@ -36,9 +32,9 @@ class DomainVisitsActionTest extends TestCase public function providingCorrectDomainReturnsVisits(string $providedDomain, string $expectedDomain): void { $apiKey = ApiKey::create(); - $getVisits = $this->visitsHelper->visitsForDomain( + $this->visitsHelper->expects($this->once())->method('visitsForDomain')->with( $expectedDomain, - Argument::type(VisitsParams::class), + $this->isInstanceOf(VisitsParams::class), $apiKey, )->willReturn(new Paginator(new ArrayAdapter([]))); @@ -48,7 +44,6 @@ class DomainVisitsActionTest extends TestCase ); self::assertEquals(200, $response->getStatusCode()); - $getVisits->shouldHaveBeenCalledOnce(); } public function provideDomainAuthorities(): iterable diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index d5f94250..bd078eaa 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -6,9 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction; @@ -16,15 +15,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class GlobalVisitsActionTest extends TestCase { - use ProphecyTrait; - private GlobalVisitsAction $action; - private ObjectProphecy $helper; + private MockObject & VisitsStatsHelperInterface $helper; protected function setUp(): void { - $this->helper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->action = new GlobalVisitsAction($this->helper->reveal()); + $this->helper = $this->createMock(VisitsStatsHelperInterface::class); + $this->action = new GlobalVisitsAction($this->helper); } /** @test */ @@ -32,13 +29,12 @@ class GlobalVisitsActionTest extends TestCase { $apiKey = ApiKey::create(); $stats = new VisitsStats(5, 3); - $getStats = $this->helper->getVisitsStats($apiKey)->willReturn($stats); + $this->helper->expects($this->once())->method('getVisitsStats')->with($apiKey)->willReturn($stats); /** @var JsonResponse $resp */ $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); $payload = $resp->getPayload(); self::assertEquals($payload, ['visits' => $stats]); - $getStats->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php index 4ecf5b88..9065d318 100644 --- a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -19,24 +17,23 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class NonOrphanVisitsActionTest extends TestCase { - use ProphecyTrait; - private NonOrphanVisitsAction $action; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->action = new NonOrphanVisitsAction($this->visitsHelper->reveal()); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->action = new NonOrphanVisitsAction($this->visitsHelper); } /** @test */ public function requestIsHandled(): void { $apiKey = ApiKey::create(); - $getVisits = $this->visitsHelper->nonOrphanVisits(Argument::type(VisitsParams::class), $apiKey)->willReturn( - new Paginator(new ArrayAdapter([])), - ); + $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->with( + $this->isInstanceOf(VisitsParams::class), + $apiKey, + )->willReturn(new Paginator(new ArrayAdapter([]))); /** @var JsonResponse $response */ $response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey)); @@ -44,6 +41,5 @@ class NonOrphanVisitsActionTest extends TestCase self::assertEquals(200, $response->getStatusCode()); self::assertArrayHasKey('visits', $payload); - $getVisits->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index ca1ae4e5..6facfb1c 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -23,18 +21,16 @@ use function count; class OrphanVisitsActionTest extends TestCase { - use ProphecyTrait; - private OrphanVisitsAction $action; - private ObjectProphecy $visitsHelper; - private ObjectProphecy $orphanVisitTransformer; + private MockObject & VisitsStatsHelperInterface $visitsHelper; + private MockObject & DataTransformerInterface $orphanVisitTransformer; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->orphanVisitTransformer = $this->prophesize(DataTransformerInterface::class); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->orphanVisitTransformer = $this->createMock(DataTransformerInterface::class); - $this->action = new OrphanVisitsAction($this->visitsHelper->reveal(), $this->orphanVisitTransformer->reveal()); + $this->action = new OrphanVisitsAction($this->visitsHelper, $this->orphanVisitTransformer); } /** @test */ @@ -42,11 +38,13 @@ class OrphanVisitsActionTest extends TestCase { $visitor = Visitor::emptyInstance(); $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; - $orphanVisits = $this->visitsHelper->orphanVisits(Argument::type(VisitsParams::class))->willReturn( - new Paginator(new ArrayAdapter($visits)), - ); + $this->visitsHelper->expects($this->once())->method('orphanVisits')->with( + $this->isInstanceOf(VisitsParams::class), + )->willReturn(new Paginator(new ArrayAdapter($visits))); $visitsAmount = count($visits); - $transform = $this->orphanVisitTransformer->transform(Argument::type(Visit::class))->willReturn([]); + $this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with( + $this->isInstanceOf(Visit::class), + )->willReturn([]); /** @var JsonResponse $response */ $response = $this->action->handle(ServerRequestFactory::fromGlobals()); @@ -54,7 +52,5 @@ class OrphanVisitsActionTest extends TestCase self::assertCount($visitsAmount, $payload['visits']['data']); self::assertEquals(200, $response->getStatusCode()); - $orphanVisits->shouldHaveBeenCalledOnce(); - $transform->shouldHaveBeenCalledTimes($visitsAmount); } } diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index be1a88e8..b9c92d6c 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Cake\Chronos\Chronos; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; @@ -22,27 +20,24 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlVisitsActionTest extends TestCase { - use ProphecyTrait; - private ShortUrlVisitsAction $action; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->action = new ShortUrlVisitsAction($this->visitsHelper->reveal()); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->action = new ShortUrlVisitsAction($this->visitsHelper); } /** @test */ public function providingCorrectShortCodeReturnsVisits(): void { $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl( + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - Argument::type(VisitsParams::class), - Argument::type(ApiKey::class), - )->willReturn(new Paginator(new ArrayAdapter([]))) - ->shouldBeCalledOnce(); + $this->isInstanceOf(VisitsParams::class), + $this->isInstanceOf(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter([]))); $response = $this->action->handle($this->requestWithApiKey()->withAttribute('shortCode', $shortCode)); self::assertEquals(200, $response->getStatusCode()); @@ -52,13 +47,15 @@ class ShortUrlVisitsActionTest extends TestCase public function paramsAreReadFromQuery(): void { $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams( - DateRange::until(Chronos::parse('2016-01-01 00:00:00')), - 3, - 10, - ), Argument::type(ApiKey::class)) - ->willReturn(new Paginator(new ArrayAdapter([]))) - ->shouldBeCalledOnce(); + $this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams( + DateRange::until(Chronos::parse('2016-01-01 00:00:00')), + 3, + 10, + ), + $this->isInstanceOf(ApiKey::class), + )->willReturn(new Paginator(new ArrayAdapter([]))); $response = $this->action->handle( $this->requestWithApiKey()->withAttribute('shortCode', $shortCode) diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index f413a9eb..1d5b9447 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -6,10 +6,8 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -18,15 +16,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagVisitsActionTest extends TestCase { - use ProphecyTrait; - private TagVisitsAction $action; - private ObjectProphecy $visitsHelper; + private MockObject & VisitsStatsHelperInterface $visitsHelper; protected function setUp(): void { - $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $this->action = new TagVisitsAction($this->visitsHelper->reveal()); + $this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class); + $this->action = new TagVisitsAction($this->visitsHelper); } /** @test */ @@ -34,15 +30,16 @@ class TagVisitsActionTest extends TestCase { $tag = 'foo'; $apiKey = ApiKey::create(); - $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::type(VisitsParams::class), $apiKey)->willReturn( - new Paginator(new ArrayAdapter([])), - ); + $this->visitsHelper->expects($this->once())->method('visitsForTag')->with( + $tag, + $this->isInstanceOf(VisitsParams::class), + $apiKey, + )->willReturn(new Paginator(new ArrayAdapter([]))); $response = $this->action->handle( ServerRequestFactory::fromGlobals()->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey), ); self::assertEquals(200, $response->getStatusCode()); - $getVisits->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php b/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php index 1db53b80..cf32ba10 100644 --- a/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php +++ b/module/Rest/test/ApiKey/InitialApiKeyDelegatorTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\ApiKey; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Mezzio\Application; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Shlinkio\Shlink\Rest\ApiKey\InitialApiKeyDelegator; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; @@ -18,15 +16,13 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class InitialApiKeyDelegatorTest extends TestCase { - use ProphecyTrait; - private InitialApiKeyDelegator $delegator; - private ObjectProphecy $container; + private MockObject & ContainerInterface $container; protected function setUp(): void { $this->delegator = new InitialApiKeyDelegator(); - $this->container = $this->prophesize(ContainerInterface::class); + $this->container = $this->createMock(ContainerInterface::class); } /** @@ -35,21 +31,21 @@ class InitialApiKeyDelegatorTest extends TestCase */ public function apiKeyIsInitializedWhenAppropriate(array $config, int $expectedCalls): void { - $app = $this->prophesize(Application::class)->reveal(); - $apiKeyRepo = $this->prophesize(ApiKeyRepositoryInterface::class); - $em = $this->prophesize(EntityManagerInterface::class); + $app = $this->createMock(Application::class); + $apiKeyRepo = $this->createMock(ApiKeyRepositoryInterface::class); + $apiKeyRepo->expects($this->exactly($expectedCalls))->method('createInitialApiKey'); + $em = $this->createMock(EntityManagerInterface::class); + $em->expects($this->exactly($expectedCalls))->method('getRepository')->with(ApiKey::class)->willReturn( + $apiKeyRepo, + ); + $this->container->expects($this->exactly($expectedCalls + 1))->method('get')->willReturnMap([ + ['config', $config], + [EntityManager::class, $em], + ]); - $getConfig = $this->container->get('config')->willReturn($config); - $getRepo = $em->getRepository(ApiKey::class)->willReturn($apiKeyRepo->reveal()); - $getEm = $this->container->get(EntityManager::class)->willReturn($em->reveal()); - - $result = ($this->delegator)($this->container->reveal(), '', fn () => $app); + $result = ($this->delegator)($this->container, '', fn () => $app); self::assertSame($result, $app); - $getConfig->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledTimes($expectedCalls); - $getEm->shouldHaveBeenCalledTimes($expectedCalls); - $apiKeyRepo->createInitialApiKey(Argument::any())->shouldHaveBeenCalledTimes($expectedCalls); } public function provideConfigs(): iterable diff --git a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php index 4198aa9b..ac513959 100644 --- a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php +++ b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php @@ -23,7 +23,8 @@ class RoleDefinitionTest extends TestCase /** @test */ public function forDomainCreatesRoleDefinitionAsExpected(): void { - $domain = Domain::withAuthority('foo.com')->setId('123'); + $domain = Domain::withAuthority('foo.com'); + $domain->setId('123'); $definition = RoleDefinition::forDomain($domain); self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->role); diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index f3cc64b2..715b89b8 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -99,9 +99,9 @@ class RoleTest extends TestCase * @test * @dataProvider provideRoleNames */ - public function getsExpectedRoleFriendlyName(Role $roleName, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void { - self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName)); + self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); } public function provideRoleNames(): iterable diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index eef78ab7..3eb77dcb 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -10,10 +10,8 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -29,21 +27,19 @@ use function Laminas\Stratigility\middleware; class AuthenticationMiddlewareTest extends TestCase { - use ProphecyTrait; - private AuthenticationMiddleware $middleware; - private ObjectProphecy $apiKeyService; - private ObjectProphecy $handler; + private MockObject & ApiKeyServiceInterface $apiKeyService; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { - $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); + $this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class); $this->middleware = new AuthenticationMiddleware( - $this->apiKeyService->reveal(), + $this->apiKeyService, [HealthAction::class], ['with_query_api_key'], ); - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); } /** @@ -52,13 +48,10 @@ class AuthenticationMiddlewareTest extends TestCase */ public function someSituationsFallbackToNextMiddleware(ServerRequestInterface $request): void { - $handle = $this->handler->handle($request)->willReturn(new Response()); - $checkApiKey = $this->apiKeyService->check(Argument::any()); + $this->handler->expects($this->once())->method('handle')->with($request)->willReturn(new Response()); + $this->apiKeyService->expects($this->never())->method('check'); - $this->middleware->process($request, $this->handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); - $checkApiKey->shouldNotHaveBeenCalled(); + $this->middleware->process($request, $this->handler); } public function provideRequestsWithoutAuth(): iterable @@ -90,12 +83,12 @@ class AuthenticationMiddlewareTest extends TestCase ServerRequestInterface $request, string $expectedMessage, ): void { - $this->apiKeyService->check(Argument::any())->shouldNotBeCalled(); - $this->handler->handle($request)->shouldNotBeCalled(); + $this->apiKeyService->expects($this->never())->method('check'); + $this->handler->expects($this->never())->method('handle'); $this->expectException(MissingAuthenticationException::class); $this->expectExceptionMessage($expectedMessage); - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware->process($request, $this->handler); } public function provideRequestsWithoutApiKey(): iterable @@ -127,12 +120,14 @@ class AuthenticationMiddlewareTest extends TestCase ) ->withHeader('X-Api-Key', $apiKey); - $this->apiKeyService->check($apiKey)->willReturn(new ApiKeyCheckResult())->shouldBeCalledOnce(); - $this->handler->handle($request)->shouldNotBeCalled(); + $this->apiKeyService->expects($this->once())->method('check')->with($apiKey)->willReturn( + new ApiKeyCheckResult(), + ); + $this->handler->expects($this->never())->method('handle'); $this->expectException(VerifyAuthenticationException::class); $this->expectExceptionMessage('Provided API key does not exist or is invalid'); - $this->middleware->process($request, $this->handler->reveal()); + $this->middleware->process($request, $this->handler); } /** @test */ @@ -147,13 +142,14 @@ class AuthenticationMiddlewareTest extends TestCase ) ->withHeader('X-Api-Key', $key); - $handle = $this->handler->handle($request->withAttribute(ApiKey::class, $apiKey))->willReturn(new Response()); - $checkApiKey = $this->apiKeyService->check($key)->willReturn(new ApiKeyCheckResult($apiKey)); + $this->handler->expects($this->once())->method('handle')->with( + $request->withAttribute(ApiKey::class, $apiKey), + )->willReturn(new Response()); + $this->apiKeyService->expects($this->once())->method('check')->with($key)->willReturn( + new ApiKeyCheckResult($apiKey), + ); - $this->middleware->process($request, $this->handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); - $checkApiKey->shouldHaveBeenCalledOnce(); + $this->middleware->process($request, $this->handler); } private function getDummyMiddleware(): MiddlewareInterface diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index f254197e..63354a76 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -7,20 +7,14 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ProphecyInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware; -use function array_shift; - class BodyParserMiddlewareTest extends TestCase { - use ProphecyTrait; - private BodyParserMiddleware $middleware; protected function setUp(): void @@ -34,11 +28,11 @@ class BodyParserMiddlewareTest extends TestCase */ public function requestsFromOtherMethodsJustFallbackToNextMiddleware(string $method): void { - $request = $this->prophesize(ServerRequestInterface::class); - $request->getMethod()->willReturn($method); - $request->getParsedBody()->willReturn([]); + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn($method); + $request->method('getParsedBody')->willReturn([]); - self::assertHandlingRequestJustFallsBackToNext($request); + $this->assertHandlingRequestJustFallsBackToNext($request); } public function provideIgnoredRequestMethods(): iterable @@ -51,25 +45,21 @@ class BodyParserMiddlewareTest extends TestCase /** @test */ public function requestsWithNonEmptyBodyJustFallbackToNextMiddleware(): void { - $request = $this->prophesize(ServerRequestInterface::class); - $request->getMethod()->willReturn('POST'); - $request->getParsedBody()->willReturn(['foo' => 'bar']); + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('POST'); + $request->method('getParsedBody')->willReturn(['foo' => 'bar']); - self::assertHandlingRequestJustFallsBackToNext($request); + $this->assertHandlingRequestJustFallsBackToNext($request); } - private function assertHandlingRequestJustFallsBackToNext(ProphecyInterface $requestMock): void + private function assertHandlingRequestJustFallsBackToNext(MockObject & ServerRequestInterface $request): void { - $getContentType = $requestMock->getHeaderLine('Content-type')->willReturn(''); - $request = $requestMock->reveal(); + $request->expects($this->never())->method('getHeaderLine'); - $nextHandler = $this->prophesize(RequestHandlerInterface::class); - $handle = $nextHandler->handle($request)->willReturn(new Response()); + $nextHandler = $this->createMock(RequestHandlerInterface::class); + $nextHandler->expects($this->once())->method('handle')->with($request)->willReturn(new Response()); - $this->middleware->process($request, $nextHandler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); - $getContentType->shouldNotHaveBeenCalled(); + $this->middleware->process($request, $nextHandler); } /** @test */ @@ -80,12 +70,11 @@ class BodyParserMiddlewareTest extends TestCase $body->write('{"foo": "bar", "bar": ["one", 5]}'); $request = (new ServerRequest())->withMethod('PUT') ->withBody($body); - $delegate = $this->prophesize(RequestHandlerInterface::class); - $process = $delegate->handle(Argument::type(ServerRequestInterface::class))->will( - function (array $args) use ($test) { - /** @var ServerRequestInterface $req */ - $req = array_shift($args); - + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->with( + $this->isInstanceOf(ServerRequestInterface::class), + )->willReturnCallback( + function (ServerRequestInterface $req) use ($test) { $test->assertEquals([ 'foo' => 'bar', 'bar' => ['one', 5], @@ -95,8 +84,6 @@ class BodyParserMiddlewareTest extends TestCase }, ); - $this->middleware->process($request, $delegate->reveal()); - - $process->shouldHaveBeenCalledOnce(); + $this->middleware->process($request, $handler); } } diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 286652bf..de87f34f 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -6,33 +6,29 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware; class CrossDomainMiddlewareTest extends TestCase { - use ProphecyTrait; - private CrossDomainMiddleware $middleware; - private ObjectProphecy $handler; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { $this->middleware = new CrossDomainMiddleware(['max_age' => 1000]); - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); } /** @test */ public function nonCrossDomainRequestsAreNotAffected(): void { $originalResponse = (new Response())->withStatus(404); - $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware->process(new ServerRequest(), $this->handler->reveal()); + $response = $this->middleware->process(new ServerRequest(), $this->handler); $headers = $response->getHeaders(); self::assertSame($originalResponse, $response); @@ -47,12 +43,9 @@ class CrossDomainMiddlewareTest extends TestCase public function anyRequestIncludesTheAllowAccessHeader(): void { $originalResponse = new Response(); - $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware->process( - (new ServerRequest())->withHeader('Origin', 'local'), - $this->handler->reveal(), - ); + $response = $this->middleware->process((new ServerRequest())->withHeader('Origin', 'local'), $this->handler); self::assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); @@ -71,9 +64,9 @@ class CrossDomainMiddlewareTest extends TestCase ->withMethod('OPTIONS') ->withHeader('Origin', 'local') ->withHeader('Access-Control-Request-Headers', 'foo, bar, baz'); - $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware->process($request, $this->handler->reveal()); + $response = $this->middleware->process($request, $this->handler); self::assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); @@ -99,9 +92,9 @@ class CrossDomainMiddlewareTest extends TestCase } $request = (new ServerRequest())->withHeader('Origin', 'local') ->withMethod('OPTIONS'); - $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware->process($request, $this->handler->reveal()); + $response = $this->middleware->process($request, $this->handler); self::assertEquals($response->getHeaderLine('Access-Control-Allow-Methods'), $expectedAllowedMethods); self::assertEquals(204, $response->getStatusCode()); @@ -126,9 +119,9 @@ class CrossDomainMiddlewareTest extends TestCase $originalResponse = (new Response())->withStatus($status); $request = (new ServerRequest())->withMethod($method) ->withHeader('Origin', 'local'); - $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); + $this->handler->expects($this->once())->method('handle')->willReturn($originalResponse); - $response = $this->middleware->process($request, $this->handler->reveal()); + $response = $this->middleware->process($request, $this->handler); self::assertEquals($expectedStatus, $response->getStatusCode()); } diff --git a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php index 00dddb2f..40ba6965 100644 --- a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php +++ b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php @@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\ErrorHandler; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -16,8 +15,6 @@ use Throwable; class BackwardsCompatibleProblemDetailsHandlerTest extends TestCase { - use ProphecyTrait; - private BackwardsCompatibleProblemDetailsHandler $handler; protected function setUp(): void @@ -26,6 +23,7 @@ class BackwardsCompatibleProblemDetailsHandlerTest extends TestCase } /** + * @param class-string $expectedException * @test * @dataProvider provideExceptions */ @@ -34,13 +32,12 @@ class BackwardsCompatibleProblemDetailsHandlerTest extends TestCase Throwable $thrownException, string $expectedException, ): void { - $handler = $this->prophesize(RequestHandlerInterface::class); - $handle = $handler->handle($request)->willThrow($thrownException); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->with($request)->willThrowException($thrownException); $this->expectException($expectedException); - $handle->shouldBeCalledOnce(); - $this->handler->process($request, $handler->reveal()); + $this->handler->process($request, $handler); } public function provideExceptions(): iterable diff --git a/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php index 138c01f0..4f576d7d 100644 --- a/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php +++ b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\Mercure; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use Mezzio\ProblemDetails\ProblemDetailsResponseFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Rest\Exception\MercureException; @@ -18,45 +16,40 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; class NotConfiguredMercureErrorHandlerTest extends TestCase { - use ProphecyTrait; - private NotConfiguredMercureErrorHandler $middleware; - private ObjectProphecy $respFactory; - private ObjectProphecy $logger; - private ObjectProphecy $handler; + private MockObject & ProblemDetailsResponseFactory $respFactory; + private MockObject & LoggerInterface $logger; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { - $this->respFactory = $this->prophesize(ProblemDetailsResponseFactory::class); - $this->logger = $this->prophesize(LoggerInterface::class); - $this->middleware = new NotConfiguredMercureErrorHandler($this->respFactory->reveal(), $this->logger->reveal()); - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->respFactory = $this->createMock(ProblemDetailsResponseFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->middleware = new NotConfiguredMercureErrorHandler($this->respFactory, $this->logger); + $this->handler = $this->createMock(RequestHandlerInterface::class); } /** @test */ public function requestHandlerIsInvokedWhenNotErrorOccurs(): void { $req = ServerRequestFactory::fromGlobals(); - $handle = $this->handler->handle($req)->willReturn(new Response()); + $this->handler->expects($this->once())->method('handle')->with($req)->willReturn(new Response()); + $this->respFactory->expects($this->never())->method('createResponseFromThrowable'); + $this->logger->expects($this->never())->method('warning'); - $this->middleware->process($req, $this->handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->respFactory->createResponseFromThrowable(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->middleware->process($req, $this->handler); } /** @test */ public function exceptionIsParsedToResponse(): void { $req = ServerRequestFactory::fromGlobals(); - $handle = $this->handler->handle($req)->willThrow(MercureException::mercureNotConfigured()); - $createResp = $this->respFactory->createResponseFromThrowable(Argument::cetera())->willReturn(new Response()); + $this->handler->expects($this->once())->method('handle')->with($req)->willThrowException( + MercureException::mercureNotConfigured(), + ); + $this->respFactory->expects($this->once())->method('createResponseFromThrowable')->willReturn(new Response()); + $this->logger->expects($this->once())->method('warning'); - $this->middleware->process($req, $this->handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); - $createResp->shouldHaveBeenCalledOnce(); - $this->logger->warning(Argument::cetera())->shouldHaveBeenCalledOnce(); + $this->middleware->process($req, $this->handler); } } diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index b77d79a9..58a3f34d 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -7,34 +7,32 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl; use Laminas\Diactoros\Response; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware; class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase { - use ProphecyTrait; - private CreateShortUrlContentNegotiationMiddleware $middleware; - private ObjectProphecy $requestHandler; + private MockObject & RequestHandlerInterface $requestHandler; protected function setUp(): void { $this->middleware = new CreateShortUrlContentNegotiationMiddleware(); - $this->requestHandler = $this->prophesize(RequestHandlerInterface::class); + $this->requestHandler = $this->createMock(RequestHandlerInterface::class); } /** @test */ public function whenNoJsonResponseIsReturnedNoFurtherOperationsArePerformed(): void { $expectedResp = new Response(); - $this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn($expectedResp); + $this->requestHandler->method('handle')->with($this->isInstanceOf(ServerRequestInterface::class))->willReturn( + $expectedResp, + ); - $resp = $this->middleware->process(new ServerRequest(), $this->requestHandler->reveal()); + $resp = $this->middleware->process(new ServerRequest(), $this->requestHandler); self::assertSame($expectedResp, $resp); } @@ -51,14 +49,13 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $request = $request->withHeader('Accept', $accept); } - $handle = $this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn( - new JsonResponse(['shortUrl' => 'http://doma.in/foo']), - ); + $this->requestHandler->expects($this->once())->method('handle')->with( + $this->isInstanceOf(ServerRequestInterface::class), + )->willReturn(new JsonResponse(['shortUrl' => 'http://doma.in/foo'])); - $response = $this->middleware->process($request, $this->requestHandler->reveal()); + $response = $this->middleware->process($request, $this->requestHandler); self::assertEquals($expectedContentType, $response->getHeaderLine('Content-type')); - $handle->shouldHaveBeenCalled(); } public function provideData(): iterable @@ -82,14 +79,13 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase { $request = (new ServerRequest())->withQueryParams(['format' => 'txt']); - $handle = $this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn( - new JsonResponse($json), - ); + $this->requestHandler->expects($this->once())->method('handle')->with( + $this->isInstanceOf(ServerRequestInterface::class), + )->willReturn(new JsonResponse($json)); - $response = $this->middleware->process($request, $this->requestHandler->reveal()); + $response = $this->middleware->process($request, $this->requestHandler); self::assertEquals($expectedBody, (string) $response->getBody()); - $handle->shouldHaveBeenCalled(); } public function provideTextBodies(): iterable diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php index 7f61830c..d8fb3092 100644 --- a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -18,14 +16,12 @@ use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware; class DefaultShortCodesLengthMiddlewareTest extends TestCase { - use ProphecyTrait; - private DefaultShortCodesLengthMiddleware $middleware; - private ObjectProphecy $handler; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); $this->middleware = new DefaultShortCodesLengthMiddleware(8); } @@ -36,17 +32,17 @@ class DefaultShortCodesLengthMiddlewareTest extends TestCase public function defaultValueIsInjectedInBodyWhenNotProvided(array $body, int $expectedLength): void { $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); - $handle = $this->handler->handle(Argument::that(function (ServerRequestInterface $req) use ($expectedLength) { - $parsedBody = $req->getParsedBody(); - Assert::assertArrayHasKey(ShortUrlInputFilter::SHORT_CODE_LENGTH, $parsedBody); - Assert::assertEquals($expectedLength, $parsedBody[ShortUrlInputFilter::SHORT_CODE_LENGTH]); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req) use ($expectedLength) { + $parsedBody = (array) $req->getParsedBody(); + Assert::assertArrayHasKey(ShortUrlInputFilter::SHORT_CODE_LENGTH, $parsedBody); + Assert::assertEquals($expectedLength, $parsedBody[ShortUrlInputFilter::SHORT_CODE_LENGTH]); - return $req; - }))->willReturn(new Response()); + return true; + }, + ))->willReturn(new Response()); - $this->middleware->process($request, $this->handler->reveal()); - - $handle->shouldHaveBeenCalledOnce(); + $this->middleware->process($request, $this->handler); } public function provideBodies(): iterable diff --git a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php index 9418a16a..1af34a48 100644 --- a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php @@ -7,24 +7,20 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware; class DropDefaultDomainFromRequestMiddlewareTest extends TestCase { - use ProphecyTrait; - private DropDefaultDomainFromRequestMiddleware $middleware; - private ObjectProphecy $next; + private MockObject & RequestHandlerInterface $next; protected function setUp(): void { - $this->next = $this->prophesize(RequestHandlerInterface::class); + $this->next = $this->createMock(RequestHandlerInterface::class); $this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in'); } @@ -36,15 +32,15 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase { $req = ServerRequestFactory::fromGlobals()->withQueryParams($providedPayload)->withParsedBody($providedPayload); - $handle = $this->next->handle(Argument::that(function (ServerRequestInterface $request) use ($expectedPayload) { - Assert::assertEquals($expectedPayload, $request->getQueryParams()); - Assert::assertEquals($expectedPayload, $request->getParsedBody()); - return $request; - }))->willReturn(new Response()); + $this->next->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $request) use ($expectedPayload) { + Assert::assertEquals($expectedPayload, $request->getQueryParams()); + Assert::assertEquals($expectedPayload, $request->getParsedBody()); + return true; + }, + ))->willReturn(new Response()); - $this->middleware->process($req, $this->next->reveal()); - - $handle->shouldHaveBeenCalledOnce(); + $this->middleware->process($req, $this->next); } public function provideQueryParams(): iterable diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index 4bf469a4..ad558abf 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; @@ -22,20 +20,18 @@ use Shlinkio\Shlink\Rest\Middleware\ShortUrl\OverrideDomainMiddleware; class OverrideDomainMiddlewareTest extends TestCase { - use ProphecyTrait; - private OverrideDomainMiddleware $middleware; - private ObjectProphecy $domainService; - private ObjectProphecy $apiKey; - private ObjectProphecy $handler; + private MockObject & DomainServiceInterface $domainService; + private MockObject & ApiKey $apiKey; + private MockObject & RequestHandlerInterface $handler; protected function setUp(): void { - $this->apiKey = $this->prophesize(ApiKey::class); - $this->handler = $this->prophesize(RequestHandlerInterface::class); + $this->apiKey = $this->createMock(ApiKey::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); - $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->middleware = new OverrideDomainMiddleware($this->domainService->reveal()); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->middleware = new OverrideDomainMiddleware($this->domainService); } /** @test */ @@ -43,16 +39,13 @@ class OverrideDomainMiddlewareTest extends TestCase { $request = $this->requestWithApiKey(); $response = new Response(); - $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(false); - $handle = $this->handler->handle($request)->willReturn($response); - $getDomain = $this->domainService->getDomain(Argument::cetera()); + $this->apiKey->expects($this->once())->method('hasRole')->with(Role::DOMAIN_SPECIFIC)->willReturn(false); + $this->handler->expects($this->once())->method('handle')->with($request)->willReturn($response); + $this->domainService->expects($this->never())->method('getDomain'); - $result = $this->middleware->process($request, $this->handler->reveal()); + $result = $this->middleware->process($request, $this->handler); self::assertSame($response, $result); - $hasRole->shouldHaveBeenCalledOnce(); - $handle->shouldHaveBeenCalledOnce(); - $getDomain->shouldNotHaveBeenCalled(); } /** @@ -62,22 +55,19 @@ class OverrideDomainMiddlewareTest extends TestCase public function overwritesRequestBodyWhenMethodIsPost(Domain $domain, array $body, array $expectedBody): void { $request = $this->requestWithApiKey()->withMethod('POST')->withParsedBody($body); - $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); - $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); - $getDomain = $this->domainService->getDomain('123')->willReturn($domain); - $handle = $this->handler->handle(Argument::that( + $this->apiKey->expects($this->once())->method('hasRole')->with(Role::DOMAIN_SPECIFIC)->willReturn(true); + $this->apiKey->expects($this->once())->method('getRoleMeta')->with(Role::DOMAIN_SPECIFIC)->willReturn( + ['domain_id' => '123'], + ); + $this->domainService->expects($this->once())->method('getDomain')->with('123')->willReturn($domain); + $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req) use ($expectedBody): bool { Assert::assertEquals($req->getParsedBody(), $expectedBody); return true; }, ))->willReturn(new Response()); - $this->middleware->process($request, $this->handler->reveal()); - - $hasRole->shouldHaveBeenCalledOnce(); - $getRoleMeta->shouldHaveBeenCalledOnce(); - $getDomain->shouldHaveBeenCalledOnce(); - $handle->shouldHaveBeenCalledOnce(); + $this->middleware->process($request, $this->handler); } public function provideBodies(): iterable @@ -112,22 +102,19 @@ class OverrideDomainMiddlewareTest extends TestCase { $domain = Domain::withAuthority('something.com'); $request = $this->requestWithApiKey()->withMethod($method); - $hasRole = $this->apiKey->hasRole(Role::DOMAIN_SPECIFIC)->willReturn(true); - $getRoleMeta = $this->apiKey->getRoleMeta(Role::DOMAIN_SPECIFIC)->willReturn(['domain_id' => '123']); - $getDomain = $this->domainService->getDomain('123')->willReturn($domain); - $handle = $this->handler->handle(Argument::that( + $this->apiKey->expects($this->once())->method('hasRole')->with(Role::DOMAIN_SPECIFIC)->willReturn(true); + $this->apiKey->expects($this->once())->method('getRoleMeta')->with(Role::DOMAIN_SPECIFIC)->willReturn( + ['domain_id' => '123'], + ); + $this->domainService->expects($this->once())->method('getDomain')->with('123')->willReturn($domain); + $this->handler->expects($this->once())->method('handle')->with($this->callback( function (ServerRequestInterface $req): bool { Assert::assertEquals($req->getAttribute(ShortUrlInputFilter::DOMAIN), 'something.com'); return true; }, ))->willReturn(new Response()); - $this->middleware->process($request, $this->handler->reveal()); - - $hasRole->shouldHaveBeenCalledOnce(); - $getRoleMeta->shouldHaveBeenCalledOnce(); - $getDomain->shouldHaveBeenCalledOnce(); - $handle->shouldHaveBeenCalledOnce(); + $this->middleware->process($request, $this->handler); } public function provideMethods(): iterable @@ -140,6 +127,6 @@ class OverrideDomainMiddlewareTest extends TestCase private function requestWithApiKey(): ServerRequestInterface { - return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $this->apiKey->reveal()); + return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $this->apiKey); } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 66bd4c28..a592313d 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -7,10 +7,8 @@ namespace ShlinkioTest\Shlink\Rest\Service; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; @@ -20,15 +18,15 @@ use Shlinkio\Shlink\Rest\Service\ApiKeyService; class ApiKeyServiceTest extends TestCase { - use ProphecyTrait; - private ApiKeyService $service; - private ObjectProphecy $em; + private MockObject & EntityManager $em; + private MockObject & EntityRepository $repo; protected function setUp(): void { - $this->em = $this->prophesize(EntityManager::class); - $this->service = new ApiKeyService($this->em->reveal()); + $this->em = $this->createMock(EntityManager::class); + $this->repo = $this->createMock(EntityRepository::class); + $this->service = new ApiKeyService($this->em); } /** @@ -38,8 +36,8 @@ class ApiKeyServiceTest extends TestCase */ public function apiKeyIsProperlyCreated(?Chronos $date, ?string $name, array $roles): void { - $this->em->flush()->shouldBeCalledOnce(); - $this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledOnce(); + $this->em->expects($this->once())->method('flush'); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); $key = $this->service->create($date, $name, ...$roles); @@ -52,10 +50,13 @@ class ApiKeyServiceTest extends TestCase public function provideCreationDate(): iterable { + $domain = Domain::withAuthority(''); + $domain->setId('123'); + yield 'no expiration date or name' => [null, null, []]; yield 'expiration date' => [Chronos::parse('2030-01-01'), null, []]; yield 'roles' => [null, null, [ - RoleDefinition::forDomain(Domain::withAuthority('')->setId('123')), + RoleDefinition::forDomain($domain), RoleDefinition::forAuthoredShortUrls(), ]]; yield 'single name' => [null, 'Alice', []]; @@ -69,10 +70,8 @@ class ApiKeyServiceTest extends TestCase */ public function checkReturnsFalseForInvalidApiKeys(?ApiKey $invalidKey): void { - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn($invalidKey) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($invalidKey); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -92,10 +91,8 @@ class ApiKeyServiceTest extends TestCase { $apiKey = ApiKey::create(); - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn($apiKey) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($apiKey); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->check('12345'); @@ -106,10 +103,8 @@ class ApiKeyServiceTest extends TestCase /** @test */ public function disableThrowsExceptionWhenNoApiKeyIsFound(): void { - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn(null) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn(null); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $this->expectException(InvalidArgumentException::class); @@ -120,12 +115,9 @@ class ApiKeyServiceTest extends TestCase public function disableReturnsDisabledApiKeyWhenFound(): void { $key = ApiKey::create(); - $repo = $this->prophesize(EntityRepository::class); - $repo->findOneBy(['key' => '12345'])->willReturn($key) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - - $this->em->flush()->shouldBeCalledOnce(); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($key); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); + $this->em->expects($this->once())->method('flush'); self::assertTrue($key->isEnabled()); $returnedKey = $this->service->disable('12345'); @@ -138,10 +130,8 @@ class ApiKeyServiceTest extends TestCase { $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; - $repo = $this->prophesize(EntityRepository::class); - $repo->findBy([])->willReturn($expectedApiKeys) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findBy')->with([])->willReturn($expectedApiKeys); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->listKeys(); @@ -153,10 +143,8 @@ class ApiKeyServiceTest extends TestCase { $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; - $repo = $this->prophesize(EntityRepository::class); - $repo->findBy(['enabled' => true])->willReturn($expectedApiKeys) - ->shouldBeCalledOnce(); - $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + $this->repo->expects($this->once())->method('findBy')->with(['enabled' => true])->willReturn($expectedApiKeys); + $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->listKeys(true); diff --git a/phpstan.neon b/phpstan.neon index aa5dab08..eee4853b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,8 @@ includes: - vendor/phpstan/phpstan-doctrine/extension.neon - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan-phpunit/rules.neon parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false