diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 37ec30df..3a6a8642 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -43,5 +43,5 @@ runs: coverage: xdebug - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.4' && '--ignore-platform-req=php' || '' }} + 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 33bf8f88..010c635f 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.2', '8.3', '8.4'] - continue-on-error: ${{ matrix.php-version == '8.4' }} env: LC_ALL: C steps: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 70fe8049..c26aaaca 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3', '8.4'] - continue-on-error: ${{ matrix.php-version == '8.4' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index a81d51fb..443d34a9 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - php-version: ['8.2', '8.3'] # TODO 8.4 + php-version: ['8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' diff --git a/CHANGELOG.md b/CHANGELOG.md index e7500a9a..be405837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,58 @@ 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). +## [4.3.0] - 2024-11-24 +### Added +* [#2159](https://github.com/shlinkio/shlink/issues/2159) Add support for PHP 8.4. +* [#2207](https://github.com/shlinkio/shlink/issues/2207) Add `hasRedirectRules` flag to short URL API model. This flag tells if a specific short URL has any redirect rules attached to it. +* [#1520](https://github.com/shlinkio/shlink/issues/1520) Allow short URLs list to be filtered by `domain`. + + This change applies both to the `GET /short-urls` endpoint, via the `domain` query parameter, and the `short-url:list` console command, via the `--domain`|`-d` flag. + +* [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system. + + * `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor. + * `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor. + +* [#2032](https://github.com/shlinkio/shlink/issues/2032) Save the URL to which a visitor is redirected when a visit is tracked. + + The value is exposed in the API as a new `redirectUrl` field for visit objects. + + This is useful to know where a visitor was redirected for a short URL with dynamic redirect rules, for special redirects, or simply in case the long URL was changed over time, and you still want to know where visitors were redirected originally. + + Some visits may not have a redirect URL if a redirect didn't happen, like for orphan visits when no special redirects are configured, or when a visit is tracked as part of the pixel action. + +### Changed +* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. + + As a side effect, API key names have now become more important, and are considered unique. + + When people update to this Shlink version, existing API keys will be hashed for everything to continue working. + + In order to avoid data to be lost, plain-text keys will be written in the `name` field, either together with any existing name, or as the name itself. Then users are responsible for renaming them using the new `api-key:rename` command. + + For newly created API keys, it is recommended to provide a name, but if not provided, a name will be generated from a redacted version of the new API key. + +* Update to Shlink PHP coding standard 2.4 +* Update to `hidehalo/nanoid-php` 2.0 +* Update to PHPStan 2.0 + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#2264](https://github.com/shlinkio/shlink/issues/2264) Fix visits counts not being deleted when deleting short URL or orphan visits. + + ## [4.2.5] - 2024-11-03 ### Added * *Nothing* ### Changed -* *Nothing* +* Update to Shlink PHP coding standard 2.4 ### Deprecated * *Nothing* diff --git a/Dockerfile b/Dockerfile index e6e94734..4f3d1ca6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.3-alpine3.19 as base +FROM php:8.3-alpine3.20 AS base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} @@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ENV USER_ID '1001' ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 ENV LC_ALL 'C' WORKDIR /etc/shlink diff --git a/README.md b/README.md index 77cbaa43..681a9495 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![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) -[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) +[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io) [![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 9a3e067a..6656c8ea 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "ext-json": "*", "ext-mbstring": "*", "ext-pdo": "*", - "akrabat/ip-address-middleware": "^2.3", + "acelaya/crawler-detect": "^1.3", + "acelaya/ip-address-middleware": "^2.4", "cakephp/chronos": "^3.1", "doctrine/dbal": "^4.2", "doctrine/migrations": "^3.8", @@ -27,9 +28,7 @@ "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.9", - "hidehalo/nanoid-php": "^1.1", - "jaybizzle/crawler-detect": "^1.2.116", - "laminas/laminas-config": "^3.9", + "hidehalo/nanoid-php": "^2.0", "laminas/laminas-config-aggregator": "^1.15", "laminas/laminas-diactoros": "^3.5", "laminas/laminas-inputfilter": "^2.30", @@ -39,17 +38,17 @@ "mezzio/mezzio": "^3.20", "mezzio/mezzio-fastroute": "^3.12", "mezzio/mezzio-problem-details": "^1.15", - "mlocati/ip-lib": "^1.18", - "mobiledetect/mobiledetectlib": "^4.8", + "mlocati/ip-lib": "^1.18.1", + "mobiledetect/mobiledetectlib": "4.8.x-dev#920c549 as 4.9", "pagerfanta/core": "^3.8", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "^6.5", - "shlinkio/shlink-config": "^3.3", + "shlinkio/shlink-common": "^6.6", + "shlinkio/shlink-config": "^3.4", "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", - "shlinkio/shlink-installer": "^9.2", - "shlinkio/shlink-ip-geolocation": "^4.1", + "shlinkio/shlink-installer": "^9.3", + "shlinkio/shlink-ip-geolocation": "^4.2", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2024.1", "spiral/roadrunner-cli": "^2.6", @@ -64,16 +63,16 @@ "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", - "phpstan/phpstan": "^1.12", - "phpstan/phpstan-doctrine": "^1.5", - "phpstan/phpstan-phpunit": "^1.4", - "phpstan/phpstan-symfony": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "phpunit/php-code-coverage": "^11.0", "phpunit/phpcov": "^10.0", "phpunit/phpunit": "^11.4", "roave/security-advisories": "dev-master", - "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^4.1.1", + "shlinkio/php-coding-standard": "~2.4.0", + "shlinkio/shlink-test-utils": "^4.2", "symfony/var-dumper": "^7.1", "veewee/composer-run-parallel": "^1.4" }, diff --git a/config/autoload/client-detection.global.php b/config/autoload/client-detection.global.php deleted file mode 100644 index a49b3d93..00000000 --- a/config/autoload/client-detection.global.php +++ /dev/null @@ -1,20 +0,0 @@ - [ - 'headers_to_inspect' => [ - 'CF-Connecting-IP', - 'X-Forwarded-For', - 'X-Forwarded', - 'Forwarded', - 'True-Client-IP', - 'X-Real-IP', - 'X-Cluster-Client-Ip', - 'Client-Ip', - ], - ], - -]; diff --git a/config/autoload/ip-address.global.php b/config/autoload/ip-address.global.php new file mode 100644 index 00000000..9d531040 --- /dev/null +++ b/config/autoload/ip-address.global.php @@ -0,0 +1,37 @@ + [ + 'ip_address' => [ + 'attribute_name' => IP_ADDRESS_REQUEST_ATTRIBUTE, + 'check_proxy_headers' => true, + 'trusted_proxies' => [], + 'headers_to_inspect' => [ + 'CF-Connecting-IP', + 'X-Forwarded-For', + 'X-Forwarded', + 'Forwarded', + 'True-Client-IP', + 'X-Real-IP', + 'X-Cluster-Client-Ip', + 'Client-Ip', + ], + ], + ], + + 'dependencies' => [ + 'factories' => [ + IpAddress::class => IpAddressFactory::class, + ], + ], + +]; diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 99f71bce..63d19d4d 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -11,6 +11,7 @@ use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware; use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; +use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; return [ @@ -67,8 +68,11 @@ return [ ], 'not-found' => [ 'middleware' => [ - // This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking + // These two middlewares are in front of other tracking actions. + // Putting them here for orphan visits tracking IpAddress::class, + IpGeolocationMiddleware::class, + Core\ErrorHandler\NotFoundTypeResolverMiddleware::class, Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware::class, Core\ErrorHandler\NotFoundTrackerMiddleware::class, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index da3d1778..1f5425b5 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -8,6 +8,7 @@ use Fig\Http\Message\RequestMethodInterface; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Core\Action as CoreAction; use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Geolocation\Middleware\IpGeolocationMiddleware; use Shlinkio\Shlink\Core\ShortUrl\Middleware\TrimTrailingSlashMiddleware; use Shlinkio\Shlink\Rest\Action; use Shlinkio\Shlink\Rest\ConfigProvider; @@ -88,6 +89,7 @@ return (static function (): array { 'path' => '/{shortCode}/track', 'middleware' => [ IpAddress::class, + IpGeolocationMiddleware::class, CoreAction\PixelAction::class, ], 'allowed_methods' => [RequestMethodInterface::METHOD_GET], @@ -105,6 +107,7 @@ return (static function (): array { 'path' => sprintf('/{shortCode}%s', $shortUrlRouteSuffix), 'middleware' => [ IpAddress::class, + IpGeolocationMiddleware::class, TrimTrailingSlashMiddleware::class, CoreAction\RedirectAction::class, ], diff --git a/config/constants.php b/config/constants.php index 20c64f19..09df0e60 100644 --- a/config/constants.php +++ b/config/constants.php @@ -21,3 +21,5 @@ const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; const DEFAULT_QR_CODE_COLOR = '#000000'; // Black const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White +const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address'; +const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url'; diff --git a/data/infra/ci/install-ms-odbc.sh b/data/infra/ci/install-ms-odbc.sh index eb3fade1..8e7f931f 100755 --- a/data/infra/ci/install-ms-odbc.sh +++ b/data/infra/ci/install-ms-odbc.sh @@ -3,7 +3,7 @@ set -ex curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - -curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list +curl https://packages.microsoft.com/config/ubuntu/24.04/prod.list > /etc/apt/sources.list.d/mssql-release.list apt-get update ACCEPT_EULA=Y apt-get install msodbcsql18 # apt-get install unixodbc-dev diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 4a7904bf..e594664b 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.3-fpm-alpine3.19 +FROM php:8.3-fpm-alpine3.20 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.23 +ENV APCU_VERSION 5.1.24 ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 RUN apk update diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 0bf251f6..198a6867 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -1,10 +1,9 @@ -FROM php:8.3-alpine3.19 +FROM php:8.3-alpine3.20 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.23 ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 +ENV MS_ODBC_DOWNLOAD '7/6/d/76de322a-d860-4894-9945-f0cc5d6a45f8' +ENV MS_ODBC_SQL_VERSION 18_18.4.1.1 RUN apk update @@ -36,16 +35,6 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ apk del .phpize-deps RUN docker-php-ext-install bcmath -# Install APCu extension -ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu \ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ - && docker-php-ext-configure apcu \ - && docker-php-ext-install apcu \ - && rm /tmp/apcu.tar.gz \ - && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ - && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini - # Install xdebug and sqlsrv driver RUN apk add --update linux-headers && \ wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 83c424ea..2d69084b 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -141,6 +141,14 @@ "crawlable": { "type": "boolean", "description": "Tells if this URL will be included as 'Allow' in Shlink's robots.txt." + }, + "forwardQuery": { + "type": "boolean", + "description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)." + }, + "hasRedirectRules": { + "type": "boolean", + "description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules." } }, "example": { @@ -164,7 +172,9 @@ }, "domain": "example.com", "title": "The title", - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } }, "ShortUrlMeta": { @@ -237,6 +247,11 @@ "type": "string", "nullable": true, "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": "string", + "nullable": true, + "description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking" } }, "example": { diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 00074acf..00f0a27b 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -15,7 +15,14 @@ "properties": { "type": { "type": "string", - "enum": ["device", "language", "query-param", "ip-address"], + "enum": [ + "device", + "language", + "query-param", + "ip-address", + "geolocation-country-code", + "geolocation-city-name" + ], "description": "The type of the condition, which will determine the logic used to match it" }, "matchKey": { diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 1535b65f..5de6f384 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -11,7 +11,8 @@ "domain", "title", "crawlable", - "forwardQuery" + "forwardQuery", + "hasRedirectRules" ], "properties": { "shortCode": { @@ -59,6 +60,10 @@ "forwardQuery": { "type": "boolean", "description": "Tells if this URL will forward the query params to the long URL when visited, as explained in [the docs](https://shlink.io/documentation/some-features/#query-params-forwarding)." + }, + "hasRedirectRules": { + "type": "boolean", + "description": "Whether this short URL has redirect rules attached to it or not. Use [this endpoint](https://api-spec.shlink.io/#/Redirect%20rules/listShortUrlRedirectRules) to get the actual list of rules." } } } diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index c4589bb1..2ccdfd23 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -25,6 +25,10 @@ "visitedUrl": { "type": ["string", "null"], "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": ["string", "null"], + "description": "The URL to which the visitor was redirected, or null if a redirect did not occur, like for 404 requests or pixel tracking" } } } diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 7d172ff4..6ca05c2e 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -125,6 +125,15 @@ "false" ] } + }, + { + "name": "domain", + "in": "query", + "description": "Get short URLs for this particular domain only. Use **DEFAULT** keyword for default domain.", + "required": false, + "schema": { + "type": "string" + } } ], "security": [ @@ -180,7 +189,9 @@ }, "domain": null, "title": "Welcome to Steam", - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": true }, { "shortCode": "12Kb3", @@ -202,7 +213,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false }, { "shortCode": "123bA", @@ -222,7 +235,9 @@ }, "domain": "example.com", "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } ], "pagination": { @@ -337,7 +352,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false } } } diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 1136aca1..17b6f97f 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -72,7 +72,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": false } }, "text/plain": { diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index c1a6eafc..11c1e0a7 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -50,7 +50,9 @@ }, "domain": null, "title": null, - "crawlable": false + "crawlable": false, + "forwardQuery": true, + "hasRedirectRules": true } } } @@ -163,7 +165,9 @@ }, "domain": null, "title": "Shlink - The URL shortener", - "crawlable": false + "crawlable": false, + "forwardQuery": false, + "hasRedirectRules": true } } } diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 8283a7b6..a554db40 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -28,6 +28,7 @@ return [ Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class, Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class, + Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class, Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 2f098998..76e7c4f5 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -59,6 +59,7 @@ return [ Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class, Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class, + Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class, Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, @@ -120,6 +121,7 @@ return [ Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class], Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class], + Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class], Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class], diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 3da85e9e..c2ed4173 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -6,39 +6,99 @@ namespace Shlinkio\Shlink\CLI\Command\Api; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; class DisableKeyCommand extends Command { public const NAME = 'api-key:disable'; - public function __construct(private ApiKeyServiceInterface $apiKeyService) + public function __construct(private readonly ApiKeyServiceInterface $apiKeyService) { parent::__construct(); } protected function configure(): void { - $this->setName(self::NAME) - ->setDescription('Disables an API key.') - ->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable'); + $help = <<%command.name% command allows you to disable an existing API key, via its name or the + plain-text key. + + If no arguments are provided, you will be prompted to select one of the existing non-disabled API keys. + + %command.full_name% + + You can optionally pass the API key name to be disabled. In that case --by-name is also + required, to indicate the first argument is the API key name and not the plain-text key: + + %command.full_name% the_key_name --by-name + + You can pass the plain-text key to be disabled, but that is DEPRECATED. In next major version, + the argument will always be assumed to be the name: + + %command.full_name% d6b6c60e-edcd-4e43-96ad-fa6b7014c143 + + HELP; + + $this + ->setName(self::NAME) + ->setDescription('Disables an API key by name or plain-text key (providing a plain-text key is DEPRECATED)') + ->addArgument( + 'keyOrName', + InputArgument::OPTIONAL, + 'The API key to disable. Pass `--by-name` to indicate this value is the name and not the key.', + ) + ->addOption( + 'by-name', + mode: InputOption::VALUE_NONE, + description: 'Indicates the first argument is the API key name, not the plain-text key.', + ) + ->setHelp($help); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $keyOrName = $input->getArgument('keyOrName'); + + if ($keyOrName === null) { + $apiKeys = $this->apiKeyService->listKeys(enabledOnly: true); + $name = (new SymfonyStyle($input, $output))->choice( + 'What API key do you want to disable?', + map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), + ); + + $input->setArgument('keyOrName', $name); + $input->setOption('by-name', true); + } } protected function execute(InputInterface $input, OutputInterface $output): int { - $apiKey = $input->getArgument('apiKey'); + $keyOrName = $input->getArgument('keyOrName'); + $byName = $input->getOption('by-name'); $io = new SymfonyStyle($input, $output); + if (! $keyOrName) { + $io->warning('An API key name was not provided.'); + return ExitCode::EXIT_WARNING; + } + try { - $this->apiKeyService->disable($apiKey); - $io->success(sprintf('API key "%s" properly disabled', $apiKey)); + if ($byName) { + $this->apiKeyService->disableByName($keyOrName); + } else { + $this->apiKeyService->disableByKey($keyOrName); + } + $io->success(sprintf('API key "%s" properly disabled', $keyOrName)); return ExitCode::EXIT_SUCCESS; } catch (InvalidArgumentException $e) { $io->error($e->getMessage()); diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 0a35bef7..9fc0bb1d 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -100,23 +100,26 @@ class GenerateKeyCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { + $io = new SymfonyStyle($input, $output); $expirationDate = $input->getOption('expiration-date'); - - $apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams( + $apiKeyMeta = ApiKeyMeta::fromParams( name: $input->getOption('name'), expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null, roleDefinitions: $this->roleResolver->determineRoles($input), - )); + ); - $io = new SymfonyStyle($input, $output); - $io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); + $apiKey = $this->apiKeyService->create($apiKeyMeta); + $io->success(sprintf('Generated API key: "%s"', $apiKeyMeta->key)); + + if ($input->isInteractive()) { + $io->warning('Save the key in a secure location. You will not be able to get it afterwards.'); + } if (! ApiKey::isAdmin($apiKey)) { ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], - $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), - null, - 'Roles', + $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, indentSize: 0)]), + headerTitle: 'Roles', ); } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 40ae8eef..d341389d 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -54,7 +54,7 @@ class ListKeysCommand extends Command $messagePattern = $this->determineMessagePattern($apiKey); // Set columns for this row - $rowData = [sprintf($messagePattern, $apiKey), sprintf($messagePattern, $apiKey->name ?? '-')]; + $rowData = [sprintf($messagePattern, $apiKey->name ?? '-')]; if (! $enabledOnly) { $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); } @@ -67,7 +67,6 @@ class ListKeysCommand extends Command }, $this->apiKeyService->listKeys($enabledOnly)); ShlinkTable::withRowSeparators($output)->render(array_filter([ - 'Key', 'Name', ! $enabledOnly ? 'Is enabled' : null, 'Expiration date', diff --git a/module/CLI/src/Command/Api/RenameApiKeyCommand.php b/module/CLI/src/Command/Api/RenameApiKeyCommand.php new file mode 100644 index 00000000..f7e24992 --- /dev/null +++ b/module/CLI/src/Command/Api/RenameApiKeyCommand.php @@ -0,0 +1,77 @@ +setName(self::NAME) + ->setDescription('Renames an API key by name') + ->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the API key to rename') + ->addArgument('newName', InputArgument::REQUIRED, 'New name to set to the API key'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + $oldName = $input->getArgument('oldName'); + $newName = $input->getArgument('newName'); + + if ($oldName === null) { + $apiKeys = $this->apiKeyService->listKeys(); + $requestedOldName = $io->choice( + 'What API key do you want to rename?', + map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name), + ); + + $input->setArgument('oldName', $requestedOldName); + } + + if ($newName === null) { + $requestedNewName = $io->ask( + 'What is the new name you want to set?', + validator: static fn (string|null $value): string => $value !== null + ? $value + : throw new InvalidArgumentException('The new name cannot be empty'), + ); + + $input->setArgument('newName', $requestedNewName); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $oldName = $input->getArgument('oldName'); + $newName = $input->getArgument('newName'); + + $this->apiKeyService->renameApiKey(Renaming::fromNames($oldName, $newName)); + $io->success('API key properly renamed'); + + return ExitCode::EXIT_SUCCESS; + } +} diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php index 1f436eeb..76ec36ae 100644 --- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php +++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php @@ -26,7 +26,7 @@ class ReadEnvVarCommand extends Command /** @var Closure(string $envVar): mixed */ private readonly Closure $loadEnvVar; - public function __construct(?Closure $loadEnvVar = null) + public function __construct(Closure|null $loadEnvVar = null) { $this->loadEnvVar = $loadEnvVar ?? static fn (string $envVar) => EnvVars::from($envVar)->loadFromEnv(); parent::__construct(); diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index c2e5e60d..61e4a93b 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -74,7 +74,7 @@ class DomainRedirectsCommand extends Command $domainAuthority = $input->getArgument('domain'); $domain = $this->domainService->findByAuthority($domainAuthority); - $ask = static function (string $message, ?string $current) use ($io): ?string { + $ask = static function (string $message, string|null $current) use ($io): string|null { if ($current === null) { return $io->ask(sprintf('%s (Leave empty for no redirect)', $message)); } diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index fdff2ddc..e3a9b180 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -22,7 +22,7 @@ class CreateShortUrlCommand extends Command { public const NAME = 'short-url:create'; - private ?SymfonyStyle $io; + private SymfonyStyle $io; private readonly ShortUrlDataInput $shortUrlDataInput; public function __construct( diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 6d38ff0f..fffeb1f6 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -10,9 +10,10 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; @@ -64,6 +65,12 @@ class ListShortUrlsCommand extends Command InputOption::VALUE_REQUIRED, 'A query used to filter results by searching for it on the longUrl and shortCode fields.', ) + ->addOption( + 'domain', + 'd', + InputOption::VALUE_REQUIRED, + 'Used to filter results by domain. Use DEFAULT keyword to filter by default domain', + ) ->addOption( 'tags', 't', @@ -111,14 +118,9 @@ class ListShortUrlsCommand extends Command 'show-api-key', 'k', InputOption::VALUE_NONE, - 'Whether to display the API key from which the URL was generated or not.', - ) - ->addOption( - 'show-api-key-name', - 'm', - InputOption::VALUE_NONE, 'Whether to display the API key name from which the URL was generated or not.', ) + ->addOption('show-api-key-name', 'm', InputOption::VALUE_NONE, '[DEPRECATED] Use show-api-key') ->addOption( 'all', 'a', @@ -134,6 +136,7 @@ class ListShortUrlsCommand extends Command $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('search-term'); + $domain = $input->getOption('domain'); $tags = $input->getOption('tags'); $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $tags = ! empty($tags) ? explode(',', $tags) : []; @@ -145,6 +148,7 @@ class ListShortUrlsCommand extends Command $data = [ ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, + ShortUrlsParamsInputFilter::DOMAIN => $domain, ShortUrlsParamsInputFilter::TAGS => $tags, ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, ShortUrlsParamsInputFilter::ORDER_BY => $orderBy, @@ -177,7 +181,7 @@ class ListShortUrlsCommand extends Command /** * @param array $columnsMap - * @return Paginator + * @return Paginator */ private function renderPage( OutputInterface $output, @@ -187,7 +191,7 @@ class ListShortUrlsCommand extends Command ): Paginator { $shortUrls = $this->shortUrlService->listShortUrls($params); - $rows = map([...$shortUrls], function (ShortUrlWithVisitsSummary $shortUrl) use ($columnsMap) { + $rows = map([...$shortUrls], function (ShortUrlWithDeps $shortUrl) use ($columnsMap) { $serializedShortUrl = $this->transformer->transform($shortUrl); return map($columnsMap, fn (callable $call) => $call($serializedShortUrl, $shortUrl->shortUrl)); }); @@ -201,7 +205,7 @@ class ListShortUrlsCommand extends Command return $shortUrls; } - private function processOrderBy(InputInterface $input): ?string + private function processOrderBy(InputInterface $input): string|null { $orderBy = $input->getOption('order-by'); if (empty($orderBy)) { @@ -231,14 +235,10 @@ class ListShortUrlsCommand extends Command } if ($input->getOption('show-domain')) { $columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->getDomain()?->authority ?? 'DEFAULT'; + $shortUrl->getDomain()->authority ?? Domain::DEFAULT_AUTHORITY; } - if ($input->getOption('show-api-key')) { - $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - $shortUrl->authorApiKey?->__toString() ?? ''; - } - if ($input->getOption('show-api-key-name')) { - $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => + if ($input->getOption('show-api-key') || $input->getOption('show-api-key-name')) { + $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): string|null => $shortUrl->authorApiKey?->name; } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index fdc0f0ce..5830858e 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -40,7 +40,7 @@ class RenameTagCommand extends Command $newName = $input->getArgument('newName'); try { - $this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName)); + $this->tagService->renameTag(Renaming::fromNames($oldName, $newName)); $io->success('Tag properly renamed.'); return ExitCode::EXIT_SUCCESS; } catch (TagNotFoundException | TagConflictException $e) { diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index dea28e92..b95c6845 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -61,8 +61,8 @@ abstract class AbstractVisitsListCommand extends Command 'date' => $visit->date->toAtomString(), 'userAgent' => $visit->userAgent, 'potentialBot' => $visit->potentialBot, - 'country' => $visit->getVisitLocation()?->countryName ?? 'Unknown', - 'city' => $visit->getVisitLocation()?->cityName ?? 'Unknown', + 'country' => $visit->getVisitLocation()->countryName ?? 'Unknown', + 'city' => $visit->getVisitLocation()->cityName ?? 'Unknown', ...$extraFields, ]; diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index ac8ee102..41674a79 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -20,7 +20,7 @@ class DownloadGeoLiteDbCommand extends Command { public const NAME = 'visit:download-db'; - private ?ProgressBar $progressBar = null; + private ProgressBar|null $progressBar = null; public function __construct(private GeolocationDbUpdaterInterface $dbUpdater) { diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index ceb5cbfd..ee31ac82 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,12 +13,12 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; - private function __construct(string $message, ?Throwable $previous = null) + private function __construct(string $message, Throwable|null $previous = null) { parent::__construct($message, previous: $previous); } - public static function withOlderDb(?Throwable $prev = null): self + public static function withOlderDb(Throwable|null $prev = null): self { $e = new self( 'An error occurred while updating geolocation database, but an older DB is already present.', @@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc return $e; } - public static function withoutOlderDb(?Throwable $prev = null): self + public static function withoutOlderDb(Throwable|null $prev = null): self { $e = new self( 'An error occurred while updating geolocation database, and an older version could not be found.', diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index 85ae1d3a..2a0fda3b 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -40,9 +40,11 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - public function checkDbUpdate(?callable $beforeDownload = null, ?callable $handleProgress = null): GeolocationResult - { - if ($this->trackingOptions->disableTracking || $this->trackingOptions->disableIpTracking) { + public function checkDbUpdate( + callable|null $beforeDownload = null, + callable|null $handleProgress = null, + ): GeolocationResult { + if (! $this->trackingOptions->isGeolocationRelevant()) { return GeolocationResult::CHECK_SKIPPED; } @@ -59,7 +61,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface /** * @throws GeolocationDbUpdateFailedException */ - private function downloadIfNeeded(?callable $beforeDownload, ?callable $handleProgress): GeolocationResult + private function downloadIfNeeded(callable|null $beforeDownload, callable|null $handleProgress): GeolocationResult { if (! $this->dbUpdater->databaseFileExists()) { return $this->downloadNewDb(false, $beforeDownload, $handleProgress); @@ -105,8 +107,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface */ private function downloadNewDb( bool $olderDbExists, - ?callable $beforeDownload, - ?callable $handleProgress, + callable|null $beforeDownload, + callable|null $handleProgress, ): GeolocationResult { if ($beforeDownload !== null) { $beforeDownload($olderDbExists); @@ -124,7 +126,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface } } - private function wrapHandleProgressCallback(?callable $handleProgress, bool $olderDbExists): ?callable + private function wrapHandleProgressCallback(callable|null $handleProgress, bool $olderDbExists): callable|null { if ($handleProgress === null) { return null; diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php index a143abb8..ba0f0e70 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdaterInterface.php @@ -12,7 +12,7 @@ interface GeolocationDbUpdaterInterface * @throws GeolocationDbUpdateFailedException */ public function checkDbUpdate( - ?callable $beforeDownload = null, - ?callable $handleProgress = null, + callable|null $beforeDownload = null, + callable|null $handleProgress = null, ): GeolocationResult; } diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 6183a6c5..74acc162 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -21,7 +21,7 @@ readonly class DateOption $command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { $value = $input->getOption($this->name); if (empty($value) || ! is_string($value)) { diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php index 8e6df28a..a38b9b32 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -readonly final class EndDateOption +final readonly class EndDateOption { private DateOption $dateOption; @@ -23,7 +23,7 @@ readonly final class EndDateOption )); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { return $this->dateOption->get($input, $output); } diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 2d3bf91e..1ff1de3f 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -18,7 +18,7 @@ use function array_unique; use function Shlinkio\Shlink\Core\ArrayUtils\flatten; use function Shlinkio\Shlink\Core\splitByComma; -readonly final class ShortUrlDataInput +final readonly class ShortUrlDataInput { public function __construct(Command $command, private bool $longUrlAsOption = false) { diff --git a/module/CLI/src/Input/ShortUrlDataOption.php b/module/CLI/src/Input/ShortUrlDataOption.php index 9774d8cb..29c41407 100644 --- a/module/CLI/src/Input/ShortUrlDataOption.php +++ b/module/CLI/src/Input/ShortUrlDataOption.php @@ -18,7 +18,7 @@ enum ShortUrlDataOption: string case CRAWLABLE = 'crawlable'; case NO_FORWARD_QUERY = 'no-forward-query'; - public function shortcut(): ?string + public function shortcut(): string|null { return match ($this) { self::TAGS => 't', diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php index c07de779..46ac79da 100644 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; -readonly final class ShortUrlIdentifierInput +final readonly class ShortUrlIdentifierInput { public function __construct(Command $command, string $shortCodeDesc, string $domainDesc) { @@ -19,7 +19,7 @@ readonly final class ShortUrlIdentifierInput ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); } - public function shortCode(InputInterface $input): ?string + public function shortCode(InputInterface $input): string|null { return $input->getArgument('shortCode'); } diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php index 6a7857d7..453b31a2 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -readonly final class StartDateOption +final readonly class StartDateOption { private DateOption $dateOption; @@ -23,7 +23,7 @@ readonly final class StartDateOption )); } - public function get(InputInterface $input, OutputInterface $output): ?Chronos + public function get(InputInterface $input, OutputInterface $output): Chronos|null { return $this->dateOption->get($input, $output); } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index cb1d3faf..89f93833 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -33,7 +33,7 @@ use const STR_PAD_LEFT; class RedirectRuleHandler implements RedirectRuleHandlerInterface { - public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array + public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null { $amountOfRules = count($rules); @@ -111,6 +111,12 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress( $this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io), ), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( + $this->askMandatory('Country code to match?', $io), + ), + RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName( + $this->askMandatory('City name to match?', $io), + ) }; $continue = $io->confirm('Do you want to add another condition?'); @@ -213,7 +219,7 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface private function askMandatory(string $message, StyleInterface $io): string { - return $io->ask($message, validator: function (?string $answer): string { + return $io->ask($message, validator: function (string|null $answer): string { if ($answer === null) { throw new InvalidArgumentException('The value is mandatory'); } @@ -223,6 +229,6 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface private function askOptional(string $message, StyleInterface $io): string { - return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); + return $io->ask($message, validator: fn (string|null $answer) => $answer === null ? '' : trim($answer)); } } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php index 16022768..e871bc81 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php @@ -16,5 +16,5 @@ interface RedirectRuleHandlerInterface * @param ShortUrlRedirectRule[] $rules * @return ShortUrlRedirectRule[]|null - A new list of rules to save, or null if no changes should be saved */ - public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array; + public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): array|null; } diff --git a/module/CLI/src/Util/ProcessRunner.php b/module/CLI/src/Util/ProcessRunner.php index 5a568dbe..af9577ea 100644 --- a/module/CLI/src/Util/ProcessRunner.php +++ b/module/CLI/src/Util/ProcessRunner.php @@ -20,7 +20,7 @@ class ProcessRunner implements ProcessRunnerInterface { private Closure $createProcess; - public function __construct(private ProcessHelper $helper, ?callable $createProcess = null) + public function __construct(private ProcessHelper $helper, callable|null $createProcess = null) { $this->createProcess = $createProcess !== null ? $createProcess(...) diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index c421c613..10823734 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -34,8 +34,12 @@ final class ShlinkTable return new self($baseTable); } - public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void - { + public function render( + array $headers, + array $rows, + string|null $footerTitle = null, + string|null $headerTitle = null, + ): void { $style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME); $style->setFooterTitleFormat(self::TABLE_TITLE_STYLE) ->setHeaderTitleFormat(self::TABLE_TITLE_STYLE); diff --git a/module/CLI/test-cli/Command/CreateShortUrlTest.php b/module/CLI/test-cli/Command/CreateShortUrlTest.php index c2e96611..b07975be 100644 --- a/module/CLI/test-cli/Command/CreateShortUrlTest.php +++ b/module/CLI/test-cli/Command/CreateShortUrlTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\CLI\Util\ExitCode; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; class CreateShortUrlTest extends CliTestCase @@ -26,6 +27,6 @@ class CreateShortUrlTest extends CliTestCase self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output); [$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]); - self::assertStringContainsString('DEFAULT', $listOutput); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput); } } diff --git a/module/CLI/test-cli/Command/ImportShortUrlsTest.php b/module/CLI/test-cli/Command/ImportShortUrlsTest.php index 1ed15d7c..40e00cc0 100644 --- a/module/CLI/test-cli/Command/ImportShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ImportShortUrlsTest.php @@ -6,6 +6,7 @@ namespace ShlinkioCliTest\Shlink\CLI\Command; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Importer\Command\ImportCommand; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; @@ -66,10 +67,10 @@ class ImportShortUrlsTest extends CliTestCase [$listOutput1] = $this->exec( [ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'], ); - self::assertStringContainsString('DEFAULT', $listOutput1); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1); [$listOutput1] = $this->exec( [ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'], ); - self::assertStringContainsString('DEFAULT', $listOutput1); + self::assertStringContainsString(Domain::DEFAULT_AUTHORITY, $listOutput1); } } diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index 46e3c135..9e0ce90d 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -26,38 +26,38 @@ class ListApiKeysTest extends CliTestCase { $expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString(); $enabledOnlyOutput = << [[], << [['-e'], $enabledOnlyOutput]; diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php index 8d9c7278..d7c50912 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -70,6 +70,23 @@ class ListShortUrlsTest extends CliTestCase | custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 | +--------------------+-------+-------------------------------------------+-------------------------------- Page 1 of 1 --------------------------------------------------------------+---------------------------+--------------+ OUTPUT]; + yield 'non-default domain' => [['--domain=example.com'], << [['-d DEFAULT'], <<apiKeyService->expects($this->once())->method('disable')->with($apiKey); + $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey); + $this->apiKeyService->expects($this->never())->method('disableByName'); - $this->commandTester->execute([ - 'apiKey' => $apiKey, + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $apiKey, ]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString('API key "abcd1234" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } #[Test] - public function errorIsReturnedIfServiceThrowsException(): void + public function providedApiKeyIsDisabledByName(): void + { + $name = 'the key to delete'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $name, + '--by-name' => true, + ]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('API key "the key to delete" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + public function errorIsReturnedIfDisableByKeyThrowsException(): void { $apiKey = 'abcd1234'; $expectedMessage = 'API key "abcd1234" does not exist.'; - $this->apiKeyService->expects($this->once())->method('disable')->with($apiKey)->willThrowException( + $this->apiKeyService->expects($this->once())->method('disableByKey')->with($apiKey)->willThrowException( new InvalidArgumentException($expectedMessage), ); + $this->apiKeyService->expects($this->never())->method('disableByName'); - $this->commandTester->execute([ - 'apiKey' => $apiKey, + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $apiKey, ]); $output = $this->commandTester->getDisplay(); self::assertStringContainsString($expectedMessage, $output); + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + } + + #[Test] + public function errorIsReturnedIfDisableByNameThrowsException(): void + { + $name = 'the key to delete'; + $expectedMessage = 'API key "the key to delete" does not exist.'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name)->willThrowException( + new InvalidArgumentException($expectedMessage), + ); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $exitCode = $this->commandTester->execute([ + 'keyOrName' => $name, + '--by-name' => true, + ]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString($expectedMessage, $output); + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + } + + #[Test] + public function warningIsReturnedIfNoArgumentIsProvidedInNonInteractiveMode(): void + { + $this->apiKeyService->expects($this->never())->method('disableByName'); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + $this->apiKeyService->expects($this->never())->method('listKeys'); + + $exitCode = $this->commandTester->execute([], ['interactive' => false]); + + self::assertEquals(ExitCode::EXIT_WARNING, $exitCode); + } + + #[Test] + public function existingApiKeyNamesAreListedIfNoArgumentIsProvidedInInteractiveMode(): void + { + $name = 'the key to delete'; + $this->apiKeyService->expects($this->once())->method('disableByName')->with($name); + $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([ + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $name)), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')), + ]); + $this->apiKeyService->expects($this->never())->method('disableByKey'); + + $this->commandTester->setInputs([$name]); + $exitCode = $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertStringContainsString('API key "the key to delete" properly disabled', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } } diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index a15ad667..1eb977bf 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; +use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; @@ -36,7 +37,7 @@ class GenerateKeyCommandTest extends TestCase public function noExpirationDateIsDefinedIfNotProvided(): void { $this->apiKeyService->expects($this->once())->method('create')->with( - $this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null), + $this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate === null), )->willReturn(ApiKey::create()); $this->commandTester->execute([]); @@ -64,8 +65,10 @@ class GenerateKeyCommandTest extends TestCase $this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'), )->willReturn(ApiKey::create()); - $this->commandTester->execute([ + $exitCode = $this->commandTester->execute([ '--name' => 'Alice', ]); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); } } diff --git a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php index 482bd36f..e86cf0e5 100644 --- a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php @@ -27,8 +27,11 @@ class InitialApiKeyCommandTest extends TestCase } #[Test, DataProvider('provideParams')] - public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void - { + public function initialKeyIsCreatedWithProvidedValue( + ApiKey|null $result, + bool $verbose, + string $expectedOutput, + ): void { $this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result); $this->commandTester->execute( diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 478dbaa5..54ae4c3e 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -52,15 +52,15 @@ class ListKeysCommandTest extends TestCase ], false, <<name} | --- | - | Admin | + +--------------------------------------+------------+---------------------------+-------+ + | {$apiKey2->name} | --- | 2020-01-01T00:00:00+00:00 | Admin | + +--------------------------------------+------------+---------------------------+-------+ + | {$apiKey3->name} | +++ | - | Admin | + +--------------------------------------+------------+---------------------------+-------+ OUTPUT, ]; @@ -68,13 +68,13 @@ class ListKeysCommandTest extends TestCase [$apiKey1 = ApiKey::create()->disable(), $apiKey2 = ApiKey::create()], true, <<name} | - | Admin | + +--------------------------------------+-----------------+-------+ + | {$apiKey2->name} | - | Admin | + +--------------------------------------+-----------------+-------+ OUTPUT, ]; @@ -94,45 +94,45 @@ class ListKeysCommandTest extends TestCase ], true, <<name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey2->name} | - | Author only | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey3->name} | - | Domain only: example.com | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey4->name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey5->name} | - | Author only | + | | | Domain only: example.com | + +--------------------------------------+-----------------+--------------------------+ + | {$apiKey6->name} | - | Admin | + +--------------------------------------+-----------------+--------------------------+ OUTPUT, ]; yield 'with names' => [ [ - $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')), - $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')), $apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')), $apiKey4 = ApiKey::create(), ], true, <<name} | - | Admin | + +--------------------------------------+-----------------+-------+ + | {$apiKey4->name} | - | Admin | + +--------------------------------------+-----------------+-------+ OUTPUT, ]; diff --git a/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php new file mode 100644 index 00000000..41e5689f --- /dev/null +++ b/module/CLI/test/Command/Api/RenameApiKeyCommandTest.php @@ -0,0 +1,83 @@ +apiKeyService = $this->createMock(ApiKeyServiceInterface::class); + $this->commandTester = CliTestUtils::testerForCommand(new RenameApiKeyCommand($this->apiKeyService)); + } + + #[Test] + public function oldNameIsRequestedIfNotProvided(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->once())->method('listKeys')->willReturn([ + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo')), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: $oldName)), + ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'bar')), + ]); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->setInputs([$oldName]); + $this->commandTester->execute([ + 'newName' => $newName, + ]); + } + + #[Test] + public function newNameIsRequestedIfNotProvided(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->never())->method('listKeys'); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->setInputs([$newName]); + $this->commandTester->execute([ + 'oldName' => $oldName, + ]); + } + + #[Test] + public function apiIsRenamedWithProvidedNames(): void + { + $oldName = 'old name'; + $newName = 'new name'; + + $this->apiKeyService->expects($this->never())->method('listKeys'); + $this->apiKeyService->expects($this->once())->method('renameApiKey')->with( + Renaming::fromNames($oldName, $newName), + ); + + $this->commandTester->execute([ + 'oldName' => $oldName, + 'newName' => $newName, + ]); + } +} diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 32240fc5..5215c2bc 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -31,7 +31,7 @@ class DomainRedirectsCommandTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void + public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(Domain|null $domain): void { $domainAuthority = 'my-domain.com'; $this->domainService->expects($this->once())->method('findByAuthority')->with($domainAuthority)->willReturn( diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index 6563abc0..e174a3b0 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetDomainVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $domain = 's.test'; diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 19c57481..bd694e7c 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -104,7 +104,7 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void + public function properlyProcessesProvidedDomain(array $input, string|null $expectedDomain): void { $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { @@ -128,8 +128,10 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideFlags')] - public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void - { + public function urlValidationHasExpectedValueBasedOnProvidedFlags( + array $options, + bool|null $expectedCrawlable, + ): void { $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) { diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index ba6735ba..a1905e38 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -93,7 +93,7 @@ class GetShortUrlVisitsCommandTest extends TestCase #[Test] public function outputIsProperlyGenerated(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 176800ab..0a7f9aa0 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -16,7 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; 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\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; @@ -25,7 +25,6 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; -use function count; use function explode; class ListShortUrlsCommandTest extends TestCase @@ -48,7 +47,7 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 50; $i++) { - $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); + $data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters() @@ -70,11 +69,11 @@ class ListShortUrlsCommandTest extends TestCase // The paginator will return more than one page $data = []; for ($i = 0; $i < 30; $i++) { - $data[] = ShortUrlWithVisitsSummary::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); + $data[] = ShortUrlWithDeps::fromShortUrl(ShortUrl::withLongUrl('https://url_' . $i)); } $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( - ShortUrlsParams::emptyInstance(), + ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter($data))); $this->commandTester->setInputs(['n']); @@ -105,105 +104,111 @@ class ListShortUrlsCommandTest extends TestCase #[Test, DataProvider('provideOptionalFlags')] public function provideOptionalFlagsMakesNewColumnsToBeIncluded( array $input, - array $expectedContents, - array $notExpectedContents, - ApiKey $apiKey, + string $expectedOutput, + ShortUrl $shortUrl, ): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with( - ShortUrlsParams::emptyInstance(), + ShortUrlsParams::empty(), )->willReturn(new Paginator(new ArrayAdapter([ - ShortUrlWithVisitsSummary::fromShortUrl( - ShortUrl::create(ShortUrlCreation::fromRawData([ - 'longUrl' => 'https://foo.com', - 'tags' => ['foo', 'bar', 'baz'], - 'apiKey' => $apiKey, - ])), - ), + ShortUrlWithDeps::fromShortUrl($shortUrl), ]))); $this->commandTester->setInputs(['y']); $this->commandTester->execute($input); $output = $this->commandTester->getDisplay(); - if (count($expectedContents) === 0 && count($notExpectedContents) === 0) { - self::fail('No expectations were run'); - } - - foreach ($expectedContents as $column) { - self::assertStringContainsString($column, $output); - } - foreach ($notExpectedContents as $column) { - self::assertStringNotContainsString($column, $output); - } + self::assertStringContainsString($expectedOutput, $output); } public static function provideOptionalFlags(): iterable { - $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')); - $key = $apiKey->toString(); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://foo.com', + 'tags' => ['foo', 'bar', 'baz'], + 'apiKey' => ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'my api key')), + ])); + $shortCode = $shortUrl->getShortCode(); + $created = $shortUrl->dateCreated()->toAtomString(); + // phpcs:disable Generic.Files.LineLength yield 'tags only' => [ ['--show-tags' => true], - ['| Tags ', '| foo, bar, baz'], - ['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'], - $apiKey, + << [ ['--show-domain' => true], - ['| Domain', '| DEFAULT'], - ['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'], - $apiKey, + << [ ['--show-api-key' => true], - ['| API Key ', $key], - ['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'], - $apiKey, - ]; - yield 'api key name only' => [ - ['--show-api-key-name' => true], - ['| API Key Name |', '| my api key'], - ['| Tags ', '| foo, bar, baz', '| API Key ', $key], - $apiKey, + << [ ['--show-tags' => true, '--show-api-key' => true], - ['| API Key ', '| Tags ', '| foo, bar, baz', $key], - ['| API Key Name |', '| my api key'], - $apiKey, + << [ ['--show-tags' => true, '--show-domain' => true], - ['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'], - ['| API Key Name |', '| my api key'], - $apiKey, + << [ - ['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true], - [ - '| API Key ', - '| Tags ', - '| API Key Name |', - '| foo, bar, baz', - $key, - '| my api key', - '| Domain', - '| DEFAULT', - ], - [], - $apiKey, + ['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true], + <<shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'page' => $page, @@ -260,7 +265,7 @@ class ListShortUrlsCommandTest extends TestCase } #[Test, DataProvider('provideOrderBy')] - public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void + public function orderByIsProperlyComputed(array $commandArgs, string|null $expectedOrderBy): void { $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ 'orderBy' => $expectedOrderBy, diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index 9b79f509..08ca2cd3 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetTagVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $tag = 'abc123'; diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 296926b8..e7fb630d 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -9,8 +9,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Tester\CommandTester; @@ -32,7 +32,7 @@ class RenameTagCommandTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames($oldName, $newName), + Renaming::fromNames($oldName, $newName), )->willThrowException(TagNotFoundException::fromTag('foo')); $this->commandTester->execute([ @@ -50,7 +50,7 @@ class RenameTagCommandTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames($oldName, $newName), + Renaming::fromNames($oldName, $newName), )->willReturn(new Tag($newName)); $this->commandTester->execute([ diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 0462c2c0..4ebe780f 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -40,7 +40,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase public function outputIsProperlyGenerated(): void { $shortUrl = ShortUrl::createFake(); - $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl($shortUrl, Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('nonOrphanVisits')->withAnyParameters()->willReturn( diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 29914b61..33a98448 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -37,7 +37,7 @@ class GetOrphanVisitsCommandTest extends TestCase #[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])] public function outputIsProperlyGenerated(array $args, bool $includesType): void { - $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forBasePath(Visitor::fromParams('bar', 'foo', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback( diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 59c6b72f..0f24a603 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -63,8 +63,8 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args, ): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); - $location = VisitLocation::fromGeolocation(Location::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams('', '', '1.2.3.4')); + $location = VisitLocation::fromGeolocation(Location::empty()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); @@ -107,7 +107,7 @@ class LocateVisitsCommandTest extends TestCase #[Test, DataProvider('provideIgnoredAddresses')] public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); @@ -134,7 +134,7 @@ class LocateVisitsCommandTest extends TestCase #[Test] public function errorWhileLocatingIpIsDisplayed(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::fromParams(remoteAddress: '1.2.3.4')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); diff --git a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php index 3196dd04..519ddf02 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -15,7 +15,7 @@ use Throwable; class GeolocationDbUpdateFailedExceptionTest extends TestCase { #[Test, DataProvider('providePrev')] - public function withOlderDbBuildsException(?Throwable $prev): void + public function withOlderDbBuildsException(Throwable|null $prev): void { $e = GeolocationDbUpdateFailedException::withOlderDb($prev); @@ -29,7 +29,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase } #[Test, DataProvider('providePrev')] - public function withoutOlderDbBuildsException(?Throwable $prev): void + public function withoutOlderDbBuildsException(Throwable|null $prev): void { $e = GeolocationDbUpdateFailedException::withoutOlderDb($prev); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 3b0f452e..038d570c 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -41,22 +41,24 @@ class GeolocationDbUpdaterTest extends TestCase #[Test] public function properResultIsReturnedWhenLicenseIsMissing(): void { - $mustBeUpdated = fn () => self::assertTrue(true); - $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); $this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException( new MissingLicenseException(''), ); $this->geoLiteDbReader->expects($this->never())->method('metadata'); - $result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); + $isCalled = false; + $result = $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void { + $isCalled = true; + }); + + self::assertTrue($isCalled); self::assertEquals(GeolocationResult::LICENSE_MISSING, $result); } #[Test] public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void { - $mustBeUpdated = fn () => self::assertTrue(true); $prev = new DbUpdateException(''); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); @@ -65,14 +67,17 @@ class GeolocationDbUpdaterTest extends TestCase )->willThrowException($prev); $this->geoLiteDbReader->expects($this->never())->method('metadata'); + $isCalled = false; try { - $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated); + $this->geolocationDbUpdater()->checkDbUpdate(function () use (&$isCalled): void { + $isCalled = true; + }); self::fail(); } catch (Throwable $e) { - /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertFalse($e->olderDbExists()); + self::assertTrue($isCalled); } } @@ -92,7 +97,6 @@ class GeolocationDbUpdaterTest extends TestCase $this->geolocationDbUpdater()->checkDbUpdate(); self::fail(); } catch (Throwable $e) { - /** @var GeolocationDbUpdateFailedException $e */ self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); self::assertSame($prev, $e->getPrevious()); self::assertTrue($e->olderDbExists()); @@ -180,7 +184,7 @@ class GeolocationDbUpdaterTest extends TestCase yield 'both' => [new TrackingOptions(disableTracking: true, disableIpTracking: true)]; } - private function geolocationDbUpdater(?TrackingOptions $options = null): GeolocationDbUpdater + private function geolocationDbUpdater(TrackingOptions|null $options = null): GeolocationDbUpdater { $locker = $this->createMock(Lock\LockFactory::class); $locker->method('createLock')->with($this->isType('string'))->willReturn($this->lock); diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index edd1eae3..eb78da61 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -56,7 +56,7 @@ class RedirectRuleHandlerTest extends TestCase #[Test, DataProvider('provideExitActions')] public function commentIsDisplayedWhenRulesListIsEmpty( RedirectRuleHandlerAction $action, - ?array $expectedResult, + array|null $expectedResult, ): void { $this->io->expects($this->once())->method('choice')->willReturn($action->value); $this->io->expects($this->once())->method('newLine'); @@ -117,6 +117,8 @@ class RedirectRuleHandlerTest extends TestCase 'Query param name?' => 'foo', 'Query param value?' => 'bar', 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', + 'Country code to match?' => 'FR', + 'City name to match?' => 'Los angeles', default => '', }, ); @@ -165,6 +167,14 @@ class RedirectRuleHandlerTest extends TestCase true, ]; yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]]; + yield 'Geolocation country code' => [ + RedirectConditionType::GEOLOCATION_COUNTRY_CODE, + [RedirectCondition::forGeolocationCountryCode('FR')], + ]; + yield 'Geolocation city name' => [ + RedirectConditionType::GEOLOCATION_CITY_NAME, + [RedirectCondition::forGeolocationCityName('Los angeles')], + ]; } #[Test] diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 552d5e2a..4844e6d5 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Core\Config\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; +use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Lock; @@ -50,6 +51,10 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, ShortUrl\Middleware\TrimTrailingSlashMiddleware::class => ConfigAbstractFactory::class, + ShortUrl\Repository\ShortUrlRepository::class => [ + EntityRepositoryFactory::class, + ShortUrl\Entity\ShortUrl::class, + ], ShortUrl\Repository\ShortUrlListRepository::class => [ EntityRepositoryFactory::class, ShortUrl\Entity\ShortUrl::class, @@ -64,8 +69,10 @@ return [ ], Tag\TagService::class => ConfigAbstractFactory::class, + Tag\Repository\TagRepository::class => [EntityRepositoryFactory::class, Tag\Entity\Tag::class], Domain\DomainService::class => ConfigAbstractFactory::class, + Domain\Repository\DomainRepository::class => [EntityRepositoryFactory::class, Domain\Entity\Domain::class], Visit\VisitsTracker::class => ConfigAbstractFactory::class, Visit\RequestTracker::class => ConfigAbstractFactory::class, @@ -96,6 +103,8 @@ return [ EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class, + Geolocation\Middleware\IpGeolocationMiddleware::class => ConfigAbstractFactory::class, + Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, Crawling\CrawlingHelper::class => ConfigAbstractFactory::class, @@ -132,6 +141,7 @@ return [ ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, ShortUrl\Helper\ShortCodeUniquenessHelper::class, EventDispatcherInterface::class, + ShortUrl\Repository\ShortUrlRepository::class, ], Visit\VisitsTracker::class => [ 'em', @@ -153,20 +163,30 @@ return [ Visit\Geolocation\VisitLocator::class => ['em', Visit\Repository\VisitIterationRepository::class], Visit\Geolocation\VisitToLocationHelper::class => [IpLocationResolverInterface::class], Visit\VisitsStatsHelper::class => ['em'], - Tag\TagService::class => ['em'], + Tag\TagService::class => ['em', Tag\Repository\TagRepository::class], ShortUrl\DeleteShortUrlService::class => [ 'em', Config\Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, ShortUrl\Repository\ExpiredShortUrlsRepository::class, ], - ShortUrl\ShortUrlResolver::class => ['em', Config\Options\UrlShortenerOptions::class], + ShortUrl\ShortUrlResolver::class => [ + ShortUrl\Repository\ShortUrlRepository::class, + Config\Options\UrlShortenerOptions::class, + ], ShortUrl\ShortUrlVisitsDeleter::class => [ Visit\Repository\VisitDeleterRepository::class, ShortUrl\ShortUrlResolver::class, ], - ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Config\Options\UrlShortenerOptions::class], - Domain\DomainService::class => ['em', Config\Options\UrlShortenerOptions::class], + ShortUrl\Helper\ShortCodeUniquenessHelper::class => [ + ShortUrl\Repository\ShortUrlRepository::class, + Config\Options\UrlShortenerOptions::class, + ], + Domain\DomainService::class => [ + 'em', + Config\Options\UrlShortenerOptions::class, + Domain\Repository\DomainRepository::class, + ], Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Config\Options\RedirectOptions::class], @@ -220,6 +240,13 @@ return [ EventDispatcher\PublishingUpdatesGenerator::class => [ShortUrl\Transformer\ShortUrlDataTransformer::class], + Geolocation\Middleware\IpGeolocationMiddleware::class => [ + IpLocationResolverInterface::class, + DbUpdater::class, + 'Logger_Shlink', + Config\Options\TrackingOptions::class, + ], + Importer\ImportedLinksProcessor::class => [ 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index b159da13..2277b0e5 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -110,4 +110,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('forward_query') ->option('default', true) ->build(); + + $builder->createOneToMany('redirectRules', RedirectRule\Entity\ShortUrlRedirectRule::class) + ->mappedBy('shortUrl') + ->fetchExtraLazy() + ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php index 7d402384..34d98572 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php @@ -75,4 +75,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('potential_bot') ->option('default', false) ->build(); + + fieldWithUtf8Charset($builder->createField('redirectUrl', Types::STRING), $emConfig) + ->columnName('redirect_url') + ->length(Visitor::REDIRECT_URL_MAX_LENGTH) + ->nullable() + ->build(); }; diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 2491d606..4e130fcf 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -15,23 +15,18 @@ use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator; use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; -use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; -use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { $regularEvents = [ - EventDispatcher\Event\UrlVisited::class => [ - EventDispatcher\LocateVisit::class, - ], EventDispatcher\Event\GeoLiteDbCreated::class => [ EventDispatcher\LocateUnlocatedVisits::class, ], ]; $asyncEvents = [ - EventDispatcher\Event\VisitLocated::class => [ + EventDispatcher\Event\UrlVisited::class => [ EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, @@ -46,9 +41,9 @@ return (static function (): array { // Send visits to matomo asynchronously if the runtime allows it if (runningInRoadRunner()) { - $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; + $asyncEvents[EventDispatcher\Event\UrlVisited::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; } else { - $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; + $regularEvents[EventDispatcher\Event\UrlVisited::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; } return [ @@ -60,7 +55,6 @@ return (static function (): array { 'dependencies' => [ 'factories' => [ - EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, @@ -104,13 +98,6 @@ return (static function (): array { ], ConfigAbstractFactory::class => [ - EventDispatcher\LocateVisit::class => [ - IpLocationResolverInterface::class, - 'em', - 'Logger_Shlink', - DbUpdater::class, - EventDispatcherInterface::class, - ], EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ MercureHubPublishingHelper::class, diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index f7bd0cdf..513e885d 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -15,9 +15,9 @@ use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use function array_keys; use function array_map; @@ -37,6 +37,8 @@ use function strtolower; use function trim; use function ucfirst; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { static $nanoIdClient; @@ -50,7 +52,7 @@ function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode: return $nanoIdClient->formattedId($alphabet, $length); } -function parseDateFromQuery(array $query, string $dateName): ?Chronos +function parseDateFromQuery(array $query, string $dateName): Chronos|null { return normalizeOptionalDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); } @@ -63,7 +65,7 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } -function dateRangeToHumanFriendly(?DateRange $dateRange): string +function dateRangeToHumanFriendly(DateRange|null $dateRange): string { $startDate = $dateRange?->startDate; $endDate = $dateRange?->endDate; @@ -83,7 +85,7 @@ function dateRangeToHumanFriendly(?DateRange $dateRange): string /** * @return ($date is null ? null : Chronos) */ -function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): ?Chronos +function normalizeOptionalDate(string|DateTimeInterface|Chronos|null $date): Chronos|null { $parsedDate = match (true) { $date === null || $date instanceof Chronos => $date, @@ -109,7 +111,7 @@ function normalizeLocale(string $locale): string * minimum quality * * @param non-empty-string $acceptLanguage - * @return iterable; + * @return iterable */ function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable { @@ -148,7 +150,7 @@ function splitLocale(string $locale): array /** * @param InputFilter $inputFilter */ -function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int +function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): int|null { $value = $inputFilter->getValue($fieldName); return $value !== null ? (int) $value : null; @@ -157,7 +159,7 @@ function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldNa /** * @param InputFilter $inputFilter */ -function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool +function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): bool|null { $value = $inputFilter->getValue($fieldName); return $value !== null ? (bool) $value : null; @@ -276,7 +278,7 @@ function enumToString(string $enum): string * Split provided string by comma and return a list of the results. * An empty array is returned if provided value is empty */ -function splitByComma(?string $value): array +function splitByComma(string|null $value): array { if ($value === null || trim($value) === '') { return []; @@ -285,7 +287,17 @@ function splitByComma(?string $value): array return array_map(trim(...), explode(',', $value)); } -function ipAddressFromRequest(ServerRequestInterface $request): ?string +function ipAddressFromRequest(ServerRequestInterface $request): string|null { - return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); + return $request->getAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE); +} + +function geolocationFromRequest(ServerRequestInterface $request): Location|null +{ + $geolocation = $request->getAttribute(Location::class); + if ($geolocation !== null && ! $geolocation instanceof Location) { + // TODO Throw exception + } + + return $geolocation; } diff --git a/module/Core/migrations/Version20241105094747.php b/module/Core/migrations/Version20241105094747.php new file mode 100644 index 00000000..4ce5548b --- /dev/null +++ b/module/Core/migrations/Version20241105094747.php @@ -0,0 +1,40 @@ +connection->quoteIdentifier('key'); + + // Append key to the name for all API keys that already have a name + $qb = $this->connection->createQueryBuilder(); + $qb->update('api_keys') + ->set('name', 'CONCAT(name, ' . $this->connection->quote(' - ') . ', ' . $keyColumnName . ')') + ->where($qb->expr()->isNotNull('name')); + $qb->executeStatement(); + + // Set plain key as name for all API keys without a name + $qb = $this->connection->createQueryBuilder(); + $qb->update('api_keys') + ->set('name', $keyColumnName) + ->where($qb->expr()->isNull('name')); + $qb->executeStatement(); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20241105215309.php b/module/Core/migrations/Version20241105215309.php new file mode 100644 index 00000000..0e9f7eff --- /dev/null +++ b/module/Core/migrations/Version20241105215309.php @@ -0,0 +1,45 @@ +connection->quoteIdentifier('key'); + + $qb = $this->connection->createQueryBuilder(); + $qb->select($keyColumnName) + ->from('api_keys'); + $result = $qb->executeQuery(); + + $updateQb = $this->connection->createQueryBuilder(); + $updateQb + ->update('api_keys') + ->set($keyColumnName, ':encryptedKey') + ->where($updateQb->expr()->eq($keyColumnName, ':plainTextKey')); + + while ($key = $result->fetchOne()) { + $updateQb->setParameters([ + 'encryptedKey' => hash('sha256', $key), + 'plainTextKey' => $key, + ])->executeStatement(); + } + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/migrations/Version20241124112257.php b/module/Core/migrations/Version20241124112257.php new file mode 100644 index 00000000..c11cbe2b --- /dev/null +++ b/module/Core/migrations/Version20241124112257.php @@ -0,0 +1,39 @@ +getTable('visits'); + $this->skipIf($visits->hasColumn(self::COLUMN_NAME)); + + $visits->addColumn(self::COLUMN_NAME, Types::STRING, [ + 'length' => 2048, + 'notnull' => false, + 'default' => null, + ]); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $this->skipIf(! $visits->hasColumn(self::COLUMN_NAME)); + $visits->dropColumn(self::COLUMN_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/src/Action/AbstractTrackingAction.php b/module/Core/src/Action/AbstractTrackingAction.php index 78eebc05..ff35828f 100644 --- a/module/Core/src/Action/AbstractTrackingAction.php +++ b/module/Core/src/Action/AbstractTrackingAction.php @@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface { public function __construct( @@ -30,9 +32,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet try { $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); + $response = $this->createSuccessResp($shortUrl, $request); + $this->requestTracker->trackIfApplicable($shortUrl, $request->withAttribute( + REDIRECT_URL_REQUEST_ATTRIBUTE, + $response->hasHeader('Location') ? $response->getHeaderLine('Location') : null, + )); - return $this->createSuccessResp($shortUrl, $request); + return $response; } catch (ShortUrlNotFoundException) { return $this->createErrorResp($request, $handler); } diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 46d90056..3be9097e 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -123,7 +123,7 @@ final class QrCodeParams return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); } - private static function parseHexColor(string $hexColor, ?string $fallback): Color + private static function parseHexColor(string $hexColor, string|null $fallback): Color { $hexColor = ltrim($hexColor, '#'); if (! ctype_xdigit($hexColor) && $fallback !== null) { diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 942cf550..a929f290 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; -use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -13,7 +12,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface +class RedirectAction extends AbstractTrackingAction { public function __construct( ShortUrlResolverInterface $urlResolver, diff --git a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php index 6ccb3848..5ec23bc1 100644 --- a/module/Core/src/Config/EmptyNotFoundRedirectConfig.php +++ b/module/Core/src/Config/EmptyNotFoundRedirectConfig.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Config; final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return null; } @@ -16,7 +16,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa return false; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return null; } @@ -26,7 +26,7 @@ final class EmptyNotFoundRedirectConfig implements NotFoundRedirectConfigInterfa return false; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return null; } diff --git a/module/Core/src/Config/NotFoundRedirectConfigInterface.php b/module/Core/src/Config/NotFoundRedirectConfigInterface.php index bbdfa9c5..46c2c734 100644 --- a/module/Core/src/Config/NotFoundRedirectConfigInterface.php +++ b/module/Core/src/Config/NotFoundRedirectConfigInterface.php @@ -6,15 +6,15 @@ namespace Shlinkio\Shlink\Core\Config; interface NotFoundRedirectConfigInterface { - public function invalidShortUrlRedirect(): ?string; + public function invalidShortUrlRedirect(): string|null; public function hasInvalidShortUrlRedirect(): bool; - public function regular404Redirect(): ?string; + public function regular404Redirect(): string|null; public function hasRegular404Redirect(): bool; - public function baseUrlRedirect(): ?string; + public function baseUrlRedirect(): string|null; public function hasBaseUrlRedirect(): bool; } diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index cfb09c8e..657336c1 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -30,7 +30,7 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, UriInterface $currentUri, - ): ?ResponseInterface { + ): ResponseInterface|null { $urlToRedirectTo = match (true) { $notFoundType->isBaseUrl() && $config->hasBaseUrlRedirect() => $config->baseUrlRedirect(), $notFoundType->isRegularNotFound() && $config->hasRegular404Redirect() => $config->regular404Redirect(), diff --git a/module/Core/src/Config/NotFoundRedirectResolverInterface.php b/module/Core/src/Config/NotFoundRedirectResolverInterface.php index 6cbdf702..5f214ca9 100644 --- a/module/Core/src/Config/NotFoundRedirectResolverInterface.php +++ b/module/Core/src/Config/NotFoundRedirectResolverInterface.php @@ -14,5 +14,5 @@ interface NotFoundRedirectResolverInterface NotFoundType $notFoundType, NotFoundRedirectConfigInterface $config, UriInterface $currentUri, - ): ?ResponseInterface; + ): ResponseInterface|null; } diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 48437924..2753d44f 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -9,16 +9,16 @@ use JsonSerializable; final class NotFoundRedirects implements JsonSerializable { private function __construct( - public readonly ?string $baseUrlRedirect, - public readonly ?string $regular404Redirect, - public readonly ?string $invalidShortUrlRedirect, + public readonly string|null $baseUrlRedirect, + public readonly string|null $regular404Redirect, + public readonly string|null $invalidShortUrlRedirect, ) { } public static function withRedirects( - ?string $baseUrlRedirect, - ?string $regular404Redirect = null, - ?string $invalidShortUrlRedirect = null, + string|null $baseUrlRedirect, + string|null $regular404Redirect = null, + string|null $invalidShortUrlRedirect = null, ): self { return new self($baseUrlRedirect, $regular404Redirect, $invalidShortUrlRedirect); } diff --git a/module/Core/src/Config/Options/NotFoundRedirectOptions.php b/module/Core/src/Config/Options/NotFoundRedirectOptions.php index e6ef6a24..7c04d077 100644 --- a/module/Core/src/Config/Options/NotFoundRedirectOptions.php +++ b/module/Core/src/Config/Options/NotFoundRedirectOptions.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigInterface { public function __construct( - public ?string $invalidShortUrl = null, - public ?string $regular404 = null, - public ?string $baseUrl = null, + public string|null $invalidShortUrl = null, + public string|null $regular404 = null, + public string|null $baseUrl = null, ) { } @@ -25,7 +25,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn ); } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrl; } @@ -35,7 +35,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn return $this->invalidShortUrl !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404; } @@ -45,7 +45,7 @@ final readonly class NotFoundRedirectOptions implements NotFoundRedirectConfigIn return $this->regular404 !== null; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return $this->baseUrl; } diff --git a/module/Core/src/Config/Options/QrCodeOptions.php b/module/Core/src/Config/Options/QrCodeOptions.php index 4d85e6cc..ac864851 100644 --- a/module/Core/src/Config/Options/QrCodeOptions.php +++ b/module/Core/src/Config/Options/QrCodeOptions.php @@ -26,7 +26,7 @@ final readonly class QrCodeOptions public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, public string $color = DEFAULT_QR_CODE_COLOR, public string $bgColor = DEFAULT_QR_CODE_BG_COLOR, - public ?string $logoUrl = null, + public string|null $logoUrl = null, ) { } diff --git a/module/Core/src/Config/Options/TrackingOptions.php b/module/Core/src/Config/Options/TrackingOptions.php index eddfba34..754978f9 100644 --- a/module/Core/src/Config/Options/TrackingOptions.php +++ b/module/Core/src/Config/Options/TrackingOptions.php @@ -22,7 +22,7 @@ final readonly class TrackingOptions public bool $trackOrphanVisits = true, // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence over // other options - public ?string $disableTrackParam = null, + public string|null $disableTrackParam = null, // If true, visits will not be tracked at all public bool $disableTracking = false, // If true, visits will be tracked, but neither the IP address, nor the location will be resolved @@ -59,4 +59,12 @@ final readonly class TrackingOptions { return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query); } + + /** + * If IP address tracking is disabled, or tracking is disabled all together, then geolocation is not relevant + */ + public function isGeolocationRelevant(): bool + { + return ! $this->disableTracking && ! $this->disableIpTracking; + } } diff --git a/module/Core/src/Crawling/CrawlingHelper.php b/module/Core/src/Crawling/CrawlingHelper.php index 958cb96e..12c0e546 100644 --- a/module/Core/src/Crawling/CrawlingHelper.php +++ b/module/Core/src/Crawling/CrawlingHelper.php @@ -6,9 +6,9 @@ namespace Shlinkio\Shlink\Core\Crawling; use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQueryInterface; -class CrawlingHelper implements CrawlingHelperInterface +readonly class CrawlingHelper implements CrawlingHelperInterface { - public function __construct(private readonly CrawlableShortCodesQueryInterface $query) + public function __construct(private CrawlableShortCodesQueryInterface $query) { } diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index e514af55..52bd6082 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -19,14 +19,17 @@ use function array_map; readonly class DomainService implements DomainServiceInterface { - public function __construct(private EntityManagerInterface $em, private UrlShortenerOptions $urlShortenerOptions) - { + public function __construct( + private EntityManagerInterface $em, + private UrlShortenerOptions $urlShortenerOptions, + private DomainRepositoryInterface $repo, + ) { } /** * @return DomainItem[] */ - public function listDomains(?ApiKey $apiKey = null): array + public function listDomains(ApiKey|null $apiKey = null): array { [$default, $domains] = $this->defaultDomainAndRest($apiKey); $mappedDomains = array_map(fn (Domain $domain) => DomainItem::forNonDefaultDomain($domain), $domains); @@ -47,11 +50,9 @@ readonly class DomainService implements DomainServiceInterface /** * @return array{Domain|null, Domain[]} */ - private function defaultDomainAndRest(?ApiKey $apiKey): array + private function defaultDomainAndRest(ApiKey|null $apiKey): array { - /** @var DomainRepositoryInterface $repo */ - $repo = $this->em->getRepository(Domain::class); - $allDomains = $repo->findDomains($apiKey); + $allDomains = $this->repo->findDomains($apiKey); $defaultDomain = null; $restOfDomains = []; @@ -71,7 +72,6 @@ readonly class DomainService implements DomainServiceInterface */ public function getDomain(string $domainId): Domain { - /** @var Domain|null $domain */ $domain = $this->em->find(Domain::class, $domainId); if ($domain === null) { throw DomainNotFoundException::fromId($domainId); @@ -80,15 +80,15 @@ readonly class DomainService implements DomainServiceInterface return $domain; } - public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain + public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null { - return $this->em->getRepository(Domain::class)->findOneByAuthority($authority, $apiKey); + return $this->repo->findOneByAuthority($authority, $apiKey); } /** * @throws DomainNotFoundException */ - public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain + public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain { $domain = $this->getPersistedDomain($authority, $apiKey); $this->em->flush(); @@ -102,7 +102,7 @@ readonly class DomainService implements DomainServiceInterface public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Domain { $domain = $this->getPersistedDomain($authority, $apiKey); $domain->configureNotFoundRedirects($notFoundRedirects); @@ -115,7 +115,7 @@ readonly class DomainService implements DomainServiceInterface /** * @throws DomainNotFoundException */ - private function getPersistedDomain(string $authority, ?ApiKey $apiKey): Domain + private function getPersistedDomain(string $authority, ApiKey|null $apiKey): Domain { $domain = $this->findByAuthority($authority, $apiKey); if ($domain === null && $apiKey?->hasRole(Role::DOMAIN_SPECIFIC)) { diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php index 103abbb2..b7f8b3ee 100644 --- a/module/Core/src/Domain/DomainServiceInterface.php +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -15,7 +15,7 @@ interface DomainServiceInterface /** * @return DomainItem[] */ - public function listDomains(?ApiKey $apiKey = null): array; + public function listDomains(ApiKey|null $apiKey = null): array; /** * @throws DomainNotFoundException @@ -25,9 +25,9 @@ interface DomainServiceInterface /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided */ - public function getOrCreate(string $authority, ?ApiKey $apiKey = null): Domain; + public function getOrCreate(string $authority, ApiKey|null $apiKey = null): Domain; - public function findByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + public function findByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null; /** * @throws DomainNotFoundException If the API key is restricted to one domain and a different one is provided @@ -35,6 +35,6 @@ interface DomainServiceInterface public function configureNotFoundRedirects( string $authority, NotFoundRedirects $notFoundRedirects, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Domain; } diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index b3d2b734..628335cd 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -11,11 +11,13 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirectConfigInterface { + public const DEFAULT_AUTHORITY = 'DEFAULT'; + private function __construct( public readonly string $authority, - private ?string $baseUrlRedirect = null, - private ?string $regular404Redirect = null, - private ?string $invalidShortUrlRedirect = null, + private string|null $baseUrlRedirect = null, + private string|null $regular404Redirect = null, + private string|null $invalidShortUrlRedirect = null, ) { } @@ -29,7 +31,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->authority; } - public function invalidShortUrlRedirect(): ?string + public function invalidShortUrlRedirect(): string|null { return $this->invalidShortUrlRedirect; } @@ -39,7 +41,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->invalidShortUrlRedirect !== null; } - public function regular404Redirect(): ?string + public function regular404Redirect(): string|null { return $this->regular404Redirect; } @@ -49,7 +51,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return $this->regular404Redirect !== null; } - public function baseUrlRedirect(): ?string + public function baseUrlRedirect(): string|null { return $this->baseUrlRedirect; } diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 53f2b6f7..6352e924 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -9,12 +9,12 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface; use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -final class DomainItem implements JsonSerializable +final readonly class DomainItem implements JsonSerializable { private function __construct( - private readonly string $authority, - public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig, - public readonly bool $isDefault, + private string $authority, + public NotFoundRedirectConfigInterface $notFoundRedirectConfig, + public bool $isDefault, ) { } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index fedf4f54..0a1fe40a 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -20,7 +20,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe /** * @return Domain[] */ - public function findDomains(?ApiKey $apiKey = null): array + public function findDomains(ApiKey|null $apiKey = null): array { $qb = $this->createQueryBuilder('d'); $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') @@ -39,7 +39,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getResult(); } - public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain + public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null { $qb = $this->createDomainQueryBuilder($authority, $apiKey); $qb->select('d'); @@ -47,7 +47,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb->getQuery()->getOneOrNullResult(); } - public function domainExists(string $authority, ?ApiKey $apiKey = null): bool + public function domainExists(string $authority, ApiKey|null $apiKey = null): bool { $qb = $this->createDomainQueryBuilder($authority, $apiKey); $qb->select('COUNT(d.id)'); @@ -55,7 +55,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; } - private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder + private function createDomainQueryBuilder(string $authority, ApiKey|null $apiKey): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Domain::class, 'd') @@ -72,7 +72,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe return $qb; } - private function determineExtraSpecs(?ApiKey $apiKey): iterable + private function determineExtraSpecs(ApiKey|null $apiKey): iterable { // FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the // ShortUrl is the root entity. Here, the Domain is the root entity. diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index d215e475..cc14bb10 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -15,9 +15,9 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio /** * @return Domain[] */ - public function findDomains(?ApiKey $apiKey = null): array; + public function findDomains(ApiKey|null $apiKey = null): array; - public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + public function findOneByAuthority(string $authority, ApiKey|null $apiKey = null): Domain|null; - public function domainExists(string $authority, ?ApiKey $apiKey = null): bool; + public function domainExists(string $authority, ApiKey|null $apiKey = null): bool; } diff --git a/module/Core/src/Domain/Spec/IsDomain.php b/module/Core/src/Domain/Spec/IsDomain.php index cf7463cc..2c78a85e 100644 --- a/module/Core/src/Domain/Spec/IsDomain.php +++ b/module/Core/src/Domain/Spec/IsDomain.php @@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class IsDomain extends BaseSpecification { - public function __construct(private string $domainId, ?string $context = null) + public function __construct(private string $domainId, string|null $context = null) { parent::__construct($context); } diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 99f7fbe6..99f71f8b 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -11,9 +11,9 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use function rtrim; -class NotFoundType +readonly class NotFoundType { - private function __construct(private readonly ?VisitType $type) + private function __construct(private VisitType|null $type) { } diff --git a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php index 4e7360d5..f84123c1 100644 --- a/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundRedirectHandler.php @@ -40,7 +40,7 @@ readonly class NotFoundRedirectHandler implements MiddlewareInterface private function resolveDomainSpecificRedirect( UriInterface $currentUri, NotFoundType $notFoundType, - ): ?ResponseInterface { + ): ResponseInterface|null { $domain = $this->domainService->findByAuthority($currentUri->getAuthority()); if ($domain === null) { return null; diff --git a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php index cd0f60be..9b59f886 100644 --- a/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php +++ b/module/Core/src/ErrorHandler/NotFoundTemplateHandler.php @@ -23,7 +23,7 @@ class NotFoundTemplateHandler implements RequestHandlerInterface private Closure $readFile; - public function __construct(?callable $readFile = null) + public function __construct(callable|null $readFile = null) { $this->readFile = $readFile ? Closure::fromCallable($readFile) : fn (string $file) => file_get_contents($file); } diff --git a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php index f3342c5a..633d83db 100644 --- a/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php +++ b/module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php @@ -10,7 +10,9 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -class NotFoundTrackerMiddleware implements MiddlewareInterface +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + +readonly class NotFoundTrackerMiddleware implements MiddlewareInterface { public function __construct(private RequestTrackerInterface $requestTracker) { @@ -18,7 +20,12 @@ class NotFoundTrackerMiddleware implements MiddlewareInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $this->requestTracker->trackNotFoundIfApplicable($request); - return $handler->handle($request); + $response = $handler->handle($request); + $this->requestTracker->trackNotFoundIfApplicable($request->withAttribute( + REDIRECT_URL_REQUEST_ATTRIBUTE, + $response->hasHeader('Location') ? $response->getHeaderLine('Location') : null, + )); + + return $response; } } diff --git a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php index 3ec9417c..e871588f 100644 --- a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Throwable; @@ -25,7 +25,7 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener ) { } - public function __invoke(VisitLocated $visitLocated): void + public function __invoke(UrlVisited $visitLocated): void { if (! $this->isEnabled()) { return; diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php deleted file mode 100644 index 87f7dba2..00000000 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ /dev/null @@ -1,27 +0,0 @@ - $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; - } - - public static function fromPayload(array $payload): self - { - return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); - } -} diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php index b6ab1a0c..4055935f 100644 --- a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; use JsonSerializable; use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; -final class ShortUrlCreated implements JsonSerializable, JsonUnserializable +final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializable { - public function __construct(public readonly string $shortUrlId) + public function __construct(public string $shortUrlId) { } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index d1158a4e..0d25b1a1 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -4,6 +4,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\EventDispatcher\Event; -final class UrlVisited extends AbstractVisitEvent +use JsonSerializable; +use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable; + +final readonly class UrlVisited implements JsonSerializable, JsonUnserializable { + final public function __construct( + public string $visitId, + public string|null $originalIpAddress = null, + ) { + } + + public function jsonSerialize(): array + { + return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress]; + } + + public static function fromPayload(array $payload): self + { + return new self($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null); + } } diff --git a/module/Core/src/EventDispatcher/Event/VisitLocated.php b/module/Core/src/EventDispatcher/Event/VisitLocated.php deleted file mode 100644 index 99b7a05e..00000000 --- a/module/Core/src/EventDispatcher/Event/VisitLocated.php +++ /dev/null @@ -1,9 +0,0 @@ -visitId; - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); - $this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress)); - } - - private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void - { - if (! $this->dbUpdater->databaseFileExists()) { - $this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [ - 'visitId' => $visitId, - ]); - return; - } - - $isLocatable = $originalIpAddress !== null || $visit->isLocatable(); - $addr = $originalIpAddress ?? $visit->remoteAddr ?? ''; - - try { - $location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance(); - - $visit->locate(VisitLocation::fromGeolocation($location)); - $this->em->flush(); - } catch (WrongIpException $e) { - $this->logger->warning( - 'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}', - ['e' => $e, 'visitId' => $visitId], - ); - } catch (Throwable $e) { - $this->logger->error( - 'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}', - ['e' => $e, 'visitId' => $visitId], - ); - } - } -} diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php index 5a85aed4..c7b5bd3c 100644 --- a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Matomo; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -22,7 +22,7 @@ readonly class SendVisitToMatomo ) { } - public function __invoke(VisitLocated $visitLocated): void + public function __invoke(UrlVisited $visitLocated): void { if (! $this->matomoOptions->enabled) { return; diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php index b762af7e..e9437cc3 100644 --- a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -48,7 +48,7 @@ final readonly class PublishingUpdatesGenerator implements PublishingUpdatesGene ]); } - private function transformShortUrl(?ShortUrl $shortUrl): array + private function transformShortUrl(ShortUrl|null $shortUrl): array { return $shortUrl === null ? [] : $this->shortUrlTransformer->transform($shortUrl); } diff --git a/module/Core/src/EventDispatcher/Topic.php b/module/Core/src/EventDispatcher/Topic.php index 0cba5a09..8c7a7d45 100644 --- a/module/Core/src/EventDispatcher/Topic.php +++ b/module/Core/src/EventDispatcher/Topic.php @@ -12,7 +12,7 @@ enum Topic: string case NEW_ORPHAN_VISIT = 'https://shlink.io/new-orphan-visit'; case NEW_SHORT_URL = 'https://shlink.io/new-short-url'; - public static function newShortUrlVisit(?string $shortCode): string + public static function newShortUrlVisit(string|null $shortCode): string { return sprintf('%s/%s', self::NEW_VISIT->value, $shortCode ?? ''); } diff --git a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php index f19378ea..4e4720c5 100644 --- a/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php +++ b/module/Core/src/EventDispatcher/UpdateGeoLiteDb.php @@ -13,12 +13,12 @@ use Throwable; use function sprintf; -class UpdateGeoLiteDb +readonly class UpdateGeoLiteDb { public function __construct( - private readonly GeolocationDbUpdaterInterface $dbUpdater, - private readonly LoggerInterface $logger, - private readonly EventDispatcherInterface $eventDispatcher, + private GeolocationDbUpdaterInterface $dbUpdater, + private LoggerInterface $logger, + private EventDispatcherInterface $eventDispatcher, ) { } diff --git a/module/Core/src/Exception/IpCannotBeLocatedException.php b/module/Core/src/Exception/IpCannotBeLocatedException.php index 2ebc3e62..d22d341f 100644 --- a/module/Core/src/Exception/IpCannotBeLocatedException.php +++ b/module/Core/src/Exception/IpCannotBeLocatedException.php @@ -13,7 +13,7 @@ class IpCannotBeLocatedException extends RuntimeException string $message, public readonly UnlocatableIpType $type, int $code = 0, - ?Throwable $previous = null, + Throwable|null $previous = null, ) { parent::__construct($message, $code, $previous); } diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 5336786c..8f9508a2 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -19,7 +19,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem private const TITLE = 'Invalid custom slug'; public const ERROR_CODE = 'non-unique-slug'; - public static function fromSlug(string $slug, ?string $domain = null): self + public static function fromSlug(string $slug, string|null $domain = null): self { $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix)); diff --git a/module/Core/src/Exception/TagConflictException.php b/module/Core/src/Exception/TagConflictException.php index 0fc5c317..e05754c7 100644 --- a/module/Core/src/Exception/TagConflictException.php +++ b/module/Core/src/Exception/TagConflictException.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use function Shlinkio\Shlink\Core\toProblemDetailsType; use function sprintf; @@ -19,7 +19,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc private const TITLE = 'Tag conflict'; public const ERROR_CODE = 'tag-conflict'; - public static function forExistingTag(TagRenaming $renaming): self + public static function forExistingTag(Renaming $renaming): self { $e = new self(sprintf('You cannot rename tag %s, because it already exists', $renaming->toString())); diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php index 95da6d5e..f81c1d37 100644 --- a/module/Core/src/Exception/ValidationException.php +++ b/module/Core/src/Exception/ValidationException.php @@ -29,12 +29,12 @@ class ValidationException extends InvalidArgumentException implements ProblemDet /** * @param InputFilterInterface $inputFilter */ - public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self + public static function fromInputFilter(InputFilterInterface $inputFilter, Throwable|null $prev = null): self { return static::fromArray($inputFilter->getMessages(), $prev); } - public static function fromArray(array $invalidData, ?Throwable $prev = null): self + public static function fromArray(array $invalidData, Throwable|null $prev = null): self { $status = StatusCodeInterface::STATUS_BAD_REQUEST; $e = new self('Provided data is not valid', $status, $prev); diff --git a/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php new file mode 100644 index 00000000..f5657e64 --- /dev/null +++ b/module/Core/src/Geolocation/Middleware/IpGeolocationMiddleware.php @@ -0,0 +1,61 @@ +trackingOptions->isGeolocationRelevant()) { + return $handler->handle($request); + } + + if (! $this->dbUpdater->databaseFileExists()) { + $this->logger->warning('Tried to geolocate IP address, but a GeoLite2 db was not found.'); + return $handler->handle($request); + } + + $location = $this->geolocateIpAddress(ipAddressFromRequest($request)); + return $handler->handle($request->withAttribute(Location::class, $location)); + } + + private function geolocateIpAddress(string|null $ipAddress): Location + { + try { + return $ipAddress === null || $ipAddress === IpAddress::LOCALHOST + ? Location::empty() + : $this->ipLocationResolver->resolveIpLocation($ipAddress); + } catch (WrongIpException $e) { + $this->logger->warning('Tried to locate IP address, but it seems to be wrong. {e}', ['e' => $e]); + return Location::empty(); + } catch (Throwable $e) { + $this->logger->error('An unexpected error occurred while trying to locate IP address. {e}', ['e' => $e]); + return Location::empty(); + } + } +} diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index 16da0a09..e8434d4f 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -8,11 +8,11 @@ use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; 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\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -25,13 +25,13 @@ use Throwable; use function Shlinkio\Shlink\Core\normalizeDate; use function sprintf; -class ImportedLinksProcessor implements ImportedLinksProcessorInterface +readonly class ImportedLinksProcessor implements ImportedLinksProcessorInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly ShortUrlRelationResolverInterface $relationResolver, - private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, - private readonly DoctrineBatchHelperInterface $batchHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private DoctrineBatchHelperInterface $batchHelper, ) { } @@ -93,7 +93,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface bool $importShortCodes, callable $skipOnShortCodeConflict, ): ShortUrlImporting { - /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); $alreadyImportedShortUrl = $shortUrlRepo->findOneByImportedUrl($importedUrl); if ($alreadyImportedShortUrl !== null) { @@ -132,7 +132,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface { $iterable = $this->batchHelper->wrapIterable($orphanVisits, 100); - /** @var VisitRepositoryInterface $visitRepo */ + /** @var VisitRepository $visitRepo */ $visitRepo = $this->em->getRepository(Visit::class); $mostRecentOrphanVisit = $visitRepo->findMostRecentOrphanVisit(); diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php index af0ace92..5f47280e 100644 --- a/module/Core/src/Matomo/MatomoOptions.php +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -13,9 +13,9 @@ final readonly class MatomoOptions */ public function __construct( public bool $enabled = false, - public ?string $baseUrl = null, + public string|null $baseUrl = null, private string|int|null $siteId = null, - public ?string $apiToken = null, + public string|null $apiToken = null, ) { } @@ -29,7 +29,7 @@ final readonly class MatomoOptions ); } - public function siteId(): ?int + public function siteId(): int|null { if ($this->siteId === null) { return null; diff --git a/module/Core/src/Matomo/MatomoVisitSender.php b/module/Core/src/Matomo/MatomoVisitSender.php index d2a4484a..9fc0176a 100644 --- a/module/Core/src/Matomo/MatomoVisitSender.php +++ b/module/Core/src/Matomo/MatomoVisitSender.php @@ -45,7 +45,7 @@ readonly class MatomoVisitSender implements MatomoVisitSenderInterface return new SendVisitsResult($successfulVisits, $failedVisits); } - public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void + public function sendVisit(Visit $visit, string|null $originalIpAddress = null): void { $tracker = $this->trackerBuilder->buildMatomoTracker(); diff --git a/module/Core/src/Matomo/MatomoVisitSenderInterface.php b/module/Core/src/Matomo/MatomoVisitSenderInterface.php index e1b1c3cb..6390104c 100644 --- a/module/Core/src/Matomo/MatomoVisitSenderInterface.php +++ b/module/Core/src/Matomo/MatomoVisitSenderInterface.php @@ -18,5 +18,5 @@ interface MatomoVisitSenderInterface VisitSendingProgressTrackerInterface|null $progressTracker = null, ): SendVisitsResult; - public function sendVisit(Visit $visit, ?string $originalIpAddress = null): void; + public function sendVisit(Visit $visit, string|null $originalIpAddress = null): void; } diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php index d4b2aaab..70db853e 100644 --- a/module/Core/src/Model/AbstractInfinitePaginableListParams.php +++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php @@ -13,18 +13,18 @@ abstract class AbstractInfinitePaginableListParams public readonly int $page; public readonly int $itemsPerPage; - protected function __construct(?int $page, ?int $itemsPerPage) + protected function __construct(int|null $page, int|null $itemsPerPage) { $this->page = $this->determinePage($page); $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage); } - private function determinePage(?int $page): int + private function determinePage(int|null $page): int { return $page === null || $page <= 0 ? self::FIRST_PAGE : $page; } - private function determineItemsPerPage(?int $itemsPerPage): int + private function determineItemsPerPage(int|null $itemsPerPage): int { return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage; } diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php index 3cd3e132..a4a15cdc 100644 --- a/module/Core/src/Model/DeviceType.php +++ b/module/Core/src/Model/DeviceType.php @@ -10,7 +10,7 @@ enum DeviceType: string case IOS = 'ios'; case DESKTOP = 'desktop'; - public static function matchFromUserAgent(string $userAgent): ?self + public static function matchFromUserAgent(string $userAgent): self|null { $detect = new MobileDetect(); $detect->setUserAgent($userAgent); diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index e1b91510..0e0edab7 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -10,7 +10,7 @@ final readonly class Ordering private const ASC_DIR = 'ASC'; private const DEFAULT_DIR = self::ASC_DIR; - public function __construct(public ?string $field = null, public string $direction = self::DEFAULT_DIR) + public function __construct(public string|null $field = null, public string $direction = self::DEFAULT_DIR) { } diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Model/Renaming.php similarity index 86% rename from module/Core/src/Tag/Model/TagRenaming.php rename to module/Core/src/Model/Renaming.php index 9c523b8b..e4cee870 100644 --- a/module/Core/src/Tag/Model/TagRenaming.php +++ b/module/Core/src/Model/Renaming.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Tag\Model; +namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Core\Exception\ValidationException; use function sprintf; -final class TagRenaming +final readonly class Renaming { - private function __construct(public readonly string $oldName, public readonly string $newName) + private function __construct(public string $oldName, public string $newName) { } diff --git a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php index 890c8845..e2a3b414 100644 --- a/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php @@ -12,7 +12,7 @@ use Pagerfanta\Adapter\AdapterInterface; */ abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface { - private ?int $count = null; + private int|null $count = null; final public function getNbResults(): int { diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 59c2798b..cf1e134b 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -12,11 +12,12 @@ use Shlinkio\Shlink\Core\Util\IpAddressUtils; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; +use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; -use function strtolower; +use function strcasecmp; use function trim; class RedirectCondition extends AbstractEntity implements JsonSerializable @@ -24,7 +25,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function __construct( private readonly RedirectConditionType $type, private readonly string $matchValue, - private readonly ?string $matchKey = null, + private readonly string|null $matchKey = null, ) { } @@ -52,6 +53,16 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern); } + public static function forGeolocationCountryCode(string $countryCode): self + { + return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); + } + + public static function forGeolocationCityName(string $cityName): self + { + return new self(RedirectConditionType::GEOLOCATION_CITY_NAME, $cityName); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -71,6 +82,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), + RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request), }; } @@ -109,7 +122,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesDevice(ServerRequestInterface $request): bool { $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - return $device !== null && $device->value === strtolower($this->matchValue); + return $device !== null && $device->value === $this->matchValue; } private function matchesRemoteIpAddress(ServerRequestInterface $request): bool @@ -118,6 +131,26 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]); } + private function matchesGeolocationCountryCode(ServerRequestInterface $request): bool + { + $geolocation = geolocationFromRequest($request); + if ($geolocation === null) { + return false; + } + + return strcasecmp($geolocation->countryCode, $this->matchValue) === 0; + } + + private function matchesGeolocationCityName(ServerRequestInterface $request): bool + { + $geolocation = geolocationFromRequest($request); + if ($geolocation === null) { + return false; + } + + return strcasecmp($geolocation->city, $this->matchValue) === 0; + } + public function jsonSerialize(): array { return [ @@ -138,6 +171,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable $this->matchValue, ), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 891a8ccc..efc314f9 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -2,10 +2,50 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Util\IpAddressUtils; + +use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function Shlinkio\Shlink\Core\enumValues; + enum RedirectConditionType: string { case DEVICE = 'device'; case LANGUAGE = 'language'; case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; + case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; + case GEOLOCATION_CITY_NAME = 'geolocation-city-name'; + + /** + * Tells if a value is valid for the condition type + */ + public function isValid(string $value): bool + { + return match ($this) { + RedirectConditionType::DEVICE => contains($value, enumValues(DeviceType::class)), + // RedirectConditionType::LANGUAGE => TODO Validate at least format, + RedirectConditionType::IP_ADDRESS => IpAddressUtils::isStaticIpCidrOrWildcard($value), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => contains($value, [ + // List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + 'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ', + 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', + 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC', + 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO', + 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', + 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', + 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', + 'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', + 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', + 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', + 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', + 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', + 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', + 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', + 'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU', + 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW', + ]), + default => true, + }; + } } diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php index c2fee661..42520a97 100644 --- a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -9,12 +9,9 @@ use Laminas\InputFilter\InputFilter; use Laminas\Validator\Callback; use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Validation\InputFactory; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; -use Shlinkio\Shlink\Core\Util\IpAddressUtils; -use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function Shlinkio\Shlink\Core\enumValues; /** @extends InputFilter */ @@ -80,11 +77,9 @@ class RedirectRulesInputFilter extends InputFilter $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); $value->getValidatorChain()->attach(new Callback( - fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) { - RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)), - RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value), - // RedirectConditionType::LANGUAGE->value => TODO, - default => true, + function (string $value, array $context): bool { + $conditionType = RedirectConditionType::tryFrom($context[self::CONDITION_TYPE]); + return $conditionType === null || $conditionType->isValid($value); }, )); $redirectConditionInputFilter->add($value); diff --git a/module/Core/src/Repository/EntityRepositoryInterface.php b/module/Core/src/Repository/EntityRepositoryInterface.php new file mode 100644 index 00000000..c6693c44 --- /dev/null +++ b/module/Core/src/Repository/EntityRepositoryInterface.php @@ -0,0 +1,20 @@ + + */ +interface EntityRepositoryInterface extends ObjectRepository +{ + /** + * @todo This should be part of ObjectRepository, so adding here until that interface defines it. + * EntityRepository already implements the method, so classes extending it won't have to add anything. + */ + public function count(array $criteria = []): int; +} diff --git a/module/Core/src/ShortUrl/DeleteShortUrlService.php b/module/Core/src/ShortUrl/DeleteShortUrlService.php index aeb08c47..b6ca5e8c 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlService.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlService.php @@ -30,7 +30,7 @@ readonly class DeleteShortUrlService implements DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): void { $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) { diff --git a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php index 32eaffa1..e511c9e5 100644 --- a/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/DeleteShortUrlServiceInterface.php @@ -18,7 +18,7 @@ interface DeleteShortUrlServiceInterface public function deleteByShortCode( ShortUrlIdentifier $identifier, bool $ignoreThreshold = false, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): void; /** diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e394fb5a..b7fb6c56 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,6 +12,7 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -39,6 +40,7 @@ class ShortUrl extends AbstractEntity * @param Collection $tags * @param Collection & Selectable $visits * @param Collection & Selectable $visitsCounts + * @param Collection $redirectRules */ private function __construct( private string $longUrl, @@ -47,19 +49,20 @@ class ShortUrl extends AbstractEntity private Collection $tags = new ArrayCollection(), private Collection & Selectable $visits = new ArrayCollection(), private Collection & Selectable $visitsCounts = new ArrayCollection(), - private ?Chronos $validSince = null, - private ?Chronos $validUntil = null, - private ?int $maxVisits = null, - private ?Domain $domain = null, + private Chronos|null $validSince = null, + private Chronos|null $validUntil = null, + private int|null $maxVisits = null, + private Domain|null $domain = null, private bool $customSlugWasProvided = false, private int $shortCodeLength = 0, - public readonly ?ApiKey $authorApiKey = null, - private ?string $title = null, + public readonly ApiKey|null $authorApiKey = null, + private string|null $title = null, private bool $titleWasAutoResolved = false, private bool $crawlable = false, private bool $forwardQuery = true, - private ?string $importSource = null, - private ?string $importOriginalShortCode = null, + private string|null $importSource = null, + private string|null $importOriginalShortCode = null, + private Collection $redirectRules = new ArrayCollection(), ) { } @@ -82,7 +85,7 @@ class ShortUrl extends AbstractEntity public static function create( ShortUrlCreation $creation, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): self { $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); $shortCodeLength = $creation->shortCodeLength; @@ -112,7 +115,7 @@ class ShortUrl extends AbstractEntity public static function fromImport( ImportedShlinkUrl $url, bool $importShortCode, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): self { $meta = [ ShortUrlInputFilter::LONG_URL => $url->longUrl, @@ -138,7 +141,7 @@ class ShortUrl extends AbstractEntity public function update( ShortUrlEdition $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null, + ShortUrlRelationResolverInterface|null $relationResolver = null, ): void { if ($shortUrlEdit->validSinceWasProvided()) { $this->validSince = $shortUrlEdit->validSince; @@ -182,7 +185,7 @@ class ShortUrl extends AbstractEntity return $this->shortCode; } - public function getDomain(): ?Domain + public function getDomain(): Domain|null { return $this->domain; } @@ -192,17 +195,22 @@ class ShortUrl extends AbstractEntity return $this->forwardQuery; } - public function title(): ?string + public function title(): string|null { return $this->title; } + public function dateCreated(): Chronos + { + return $this->dateCreated; + } + public function reachedVisits(int $visitsAmount): bool { return count($this->visits) >= $visitsAmount; } - public function mostRecentImportedVisitDate(): ?Chronos + public function mostRecentImportedVisitDate(): Chronos|null { $criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED)) ->orderBy(['id' => 'DESC']) @@ -261,7 +269,13 @@ class ShortUrl extends AbstractEntity return true; } - public function toArray(?VisitsSummary $precalculatedSummary = null): array + /** + * @param null|(callable(): ?string) $getAuthority - + * This is a callback so that we trust its return value if provided, even if it is null. + * Providing the raw authority as `string|null` would result in a fallback to `$this->domain` when the authority + * was null. + */ + public function toArray(VisitsSummary|null $precalculatedSummary = null, callable|null $getAuthority = null): array { return [ 'shortCode' => $this->shortCode, @@ -273,7 +287,7 @@ class ShortUrl extends AbstractEntity 'validUntil' => $this->validUntil?->toAtomString(), 'maxVisits' => $this->maxVisits, ], - 'domain' => $this->domain, + 'domain' => $getAuthority !== null ? $getAuthority() : $this->domain?->authority, 'title' => $this->title, 'crawlable' => $this->crawlable, 'forwardQuery' => $this->forwardQuery, @@ -283,6 +297,7 @@ class ShortUrl extends AbstractEntity Criteria::create()->where(Criteria::expr()->eq('potentialBot', false)), )), ), + 'hasRedirectRules' => count($this->redirectRules) > 0, ]; } } diff --git a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php index 7f863f6c..7c7f2a76 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -4,25 +4,21 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; -class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface +readonly class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface { - public function __construct( - private readonly EntityManagerInterface $em, - private readonly UrlShortenerOptions $options, - ) { + public function __construct(private ShortUrlRepositoryInterface $repo, private UrlShortenerOptions $options) + { } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool { - /** @var ShortUrlRepository $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - $otherShortUrlsExist = $repo->shortCodeIsInUseWithLock(ShortUrlIdentifier::fromShortUrl($shortUrlToBeCreated)); + $identifier = ShortUrlIdentifier::fromShortUrl($shortUrlToBeCreated); + $otherShortUrlsExist = $this->repo->shortCodeIsInUseWithLock($identifier); if (! $otherShortUrlsExist) { return true; diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index 375d8837..47ac25bf 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -25,7 +25,7 @@ readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderI public function buildShortUrlRedirect( ShortUrl $shortUrl, ServerRequestInterface $request, - ?string $extraPath = null, + string|null $extraPath = null, ): string { $uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request)); $shouldForwardQuery = $shortUrl->forwardQuery(); @@ -58,7 +58,7 @@ readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderI return Query::build($mergedQuery); } - private function resolvePath(string $basePath, ?string $extraPath): string + private function resolvePath(string $basePath, string|null $extraPath): string { return $extraPath === null ? $basePath : sprintf('%s%s', $basePath, $extraPath); } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php index 7f79e98a..849a3b3f 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -12,6 +12,6 @@ interface ShortUrlRedirectionBuilderInterface public function buildShortUrlRedirect( ShortUrl $shortUrl, ServerRequestInterface $request, - ?string $extraPath = null, + string|null $extraPath = null, ): string; } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 6659bc0c..36dd9a60 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\Uri; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use function sprintf; @@ -18,19 +19,20 @@ readonly class ShortUrlStringifier implements ShortUrlStringifierInterface ) { } - public function stringify(ShortUrl $shortUrl): string + public function stringify(ShortUrl|ShortUrlIdentifier $shortUrl): string { + $shortUrlIdentifier = $shortUrl instanceof ShortUrl ? ShortUrlIdentifier::fromShortUrl($shortUrl) : $shortUrl; $uriWithoutShortCode = (new Uri())->withScheme($this->urlShortenerOptions->schema) - ->withHost($this->resolveDomain($shortUrl)) + ->withHost($this->resolveDomain($shortUrlIdentifier)) ->withPath($this->basePath) ->__toString(); // The short code needs to be appended to avoid it from being URL-encoded - return sprintf('%s/%s', $uriWithoutShortCode, $shortUrl->getShortCode()); + return sprintf('%s/%s', $uriWithoutShortCode, $shortUrlIdentifier->shortCode); } - private function resolveDomain(ShortUrl $shortUrl): string + private function resolveDomain(ShortUrlIdentifier $shortUrlIdentifier): string { - return $shortUrl->getDomain()?->authority ?? $this->urlShortenerOptions->defaultDomain; + return $shortUrlIdentifier->domain ?? $this->urlShortenerOptions->defaultDomain; } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php index 0505a694..0a6f6975 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifierInterface.php @@ -5,8 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; interface ShortUrlStringifierInterface { - public function stringify(ShortUrl $shortUrl): string; + public function stringify(ShortUrl|ShortUrlIdentifier $shortUrl): string; } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 0950e042..df52c92d 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -61,7 +61,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH return $title !== null ? $data->withResolvedTitle($title) : $data; } - private function fetchUrl(string $url): ?ResponseInterface + private function fetchUrl(string $url): ResponseInterface|null { try { return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [ @@ -80,7 +80,7 @@ readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionH } } - private function tryToResolveTitle(ResponseInterface $response, string $contentType): ?string + private function tryToResolveTitle(ResponseInterface $response, string $contentType): string|null { $collectedBody = ''; $body = $response->getBody(); diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index b164ffd6..4b013b33 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -25,14 +25,16 @@ use function implode; use function sprintf; use function trim; -class ExtraPathRedirectMiddleware implements MiddlewareInterface +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + +readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( - private readonly ShortUrlResolverInterface $resolver, - private readonly RequestTrackerInterface $requestTracker, - private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private readonly RedirectResponseHelperInterface $redirectResponseHelper, - private readonly UrlShortenerOptions $urlShortenerOptions, + private ShortUrlResolverInterface $resolver, + private RequestTrackerInterface $requestTracker, + private ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private RedirectResponseHelperInterface $redirectResponseHelper, + private UrlShortenerOptions $urlShortenerOptions, ) { } @@ -47,7 +49,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface return $this->tryToResolveRedirect($request, $handler); } - private function shouldApplyLogic(?NotFoundType $notFoundType): bool + private function shouldApplyLogic(NotFoundType|null $notFoundType): bool { if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath) { return false; @@ -73,9 +75,12 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface try { $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); - $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); + $this->requestTracker->trackIfApplicable( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $longUrl), + ); + return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index c9c85e1b..778e8d00 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -26,17 +26,17 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface private function __construct( public string $longUrl, public ShortUrlMode $shortUrlMode, - public ?Chronos $validSince = null, - public ?Chronos $validUntil = null, - public ?string $customSlug = null, - public ?string $pathPrefix = null, - public ?int $maxVisits = null, + public Chronos|null $validSince = null, + public Chronos|null $validUntil = null, + public string|null $customSlug = null, + public string|null $pathPrefix = null, + public int|null $maxVisits = null, public bool $findIfExists = false, - public ?string $domain = null, + public string|null $domain = null, public int $shortCodeLength = 5, - public ?ApiKey $apiKey = null, + public ApiKey|null $apiKey = null, public array $tags = [], - public ?string $title = null, + public string|null $title = null, public bool $titleWasAutoResolved = false, public bool $crawlable = false, public bool $forwardQuery = true, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 6296f84d..69b571d0 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -21,17 +21,17 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface */ private function __construct( private bool $longUrlPropWasProvided = false, - public ?string $longUrl = null, + public string|null $longUrl = null, private bool $validSincePropWasProvided = false, - public ?Chronos $validSince = null, + public Chronos|null $validSince = null, private bool $validUntilPropWasProvided = false, - public ?Chronos $validUntil = null, + public Chronos|null $validUntil = null, private bool $maxVisitsPropWasProvided = false, - public ?int $maxVisits = null, + public int|null $maxVisits = null, private bool $tagsPropWasProvided = false, public array $tags = [], private bool $titlePropWasProvided = false, - public ?string $title = null, + public string|null $title = null, public bool $titleWasAutoResolved = false, private bool $crawlablePropWasProvided = false, public bool $crawlable = false, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index a7c2e2ff..9b3014f8 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -11,7 +11,7 @@ use function sprintf; final readonly class ShortUrlIdentifier { - private function __construct(public string $shortCode, public ?string $domain = null) + private function __construct(public string $shortCode, public string|null $domain = null) { } @@ -33,13 +33,11 @@ final readonly class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { - $domain = $shortUrl->getDomain(); - $domainAuthority = $domain?->authority; - - return new self($shortUrl->getShortCode(), $domainAuthority); + $domain = $shortUrl->getDomain()?->authority; + return new self($shortUrl->getShortCode(), $domain); } - public static function fromShortCodeAndDomain(string $shortCode, ?string $domain = null): self + public static function fromShortCodeAndDomain(string $shortCode, string|null $domain = null): self { return new self($shortCode, $domain); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php b/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php new file mode 100644 index 00000000..4b9b5a70 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ShortUrlWithDeps.php @@ -0,0 +1,48 @@ +getDomain()?->authority); + } + + public function toIdentifier(): ShortUrlIdentifier + { + return ShortUrlIdentifier::fromShortCodeAndDomain($this->shortUrl->getShortCode(), $this->authority); + } + + public function toArray(): array + { + return $this->shortUrl->toArray($this->visitsSummary, fn() => $this->authority); + } +} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php b/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php deleted file mode 100644 index 50efaaee..00000000 --- a/module/Core/src/ShortUrl/Model/ShortUrlWithVisitsSummary.php +++ /dev/null @@ -1,36 +0,0 @@ -shortUrl->toArray($this->visitsSummary); - } -} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php index 88e20aa7..7b68ed37 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlsParams.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlsParams.php @@ -19,17 +19,18 @@ final class ShortUrlsParams private function __construct( public readonly int $page, public readonly int $itemsPerPage, - public readonly ?string $searchTerm, + public readonly string|null $searchTerm, public readonly array $tags, public readonly Ordering $orderBy, - public readonly ?DateRange $dateRange, + public readonly DateRange|null $dateRange, public readonly bool $excludeMaxVisitsReached, public readonly bool $excludePastValidUntil, public readonly TagsMode $tagsMode = TagsMode::ANY, + public readonly string|null $domain = null, ) { } - public static function emptyInstance(): self + public static function empty(): self { return self::fromRawData([]); } @@ -59,10 +60,11 @@ final class ShortUrlsParams excludeMaxVisitsReached: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED), excludePastValidUntil: $inputFilter->getValue(ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL), tagsMode: self::resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)), + domain: $inputFilter->getValue(ShortUrlsParamsInputFilter::DOMAIN), ); } - private static function resolveTagsMode(?string $rawTagsMode): TagsMode + private static function resolveTagsMode(string|null $rawTagsMode): TagsMode { if ($rawTagsMode === null) { return TagsMode::ANY; diff --git a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php index b9d4f993..a710b63a 100644 --- a/module/Core/src/ShortUrl/Model/UrlShorteningResult.php +++ b/module/Core/src/ShortUrl/Model/UrlShorteningResult.php @@ -7,11 +7,11 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Throwable; -final class UrlShorteningResult +final readonly class UrlShorteningResult { private function __construct( - public readonly ShortUrl $shortUrl, - private readonly ?Throwable $errorOnEventDispatching, + public ShortUrl $shortUrl, + private Throwable|null $errorOnEventDispatching, ) { } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 3cd7744a..5cd7fe38 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -109,7 +109,7 @@ class ShortUrlInputFilter extends InputFilter $title = InputFactory::basic(self::TITLE); $title->getFilterChain()->attach(new Filter\Callback( - static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), + static fn (string|null $value) => $value === null ? $value : substr($value, 0, 512), )); $this->add($title); diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index 0a0d45ed..600ebc33 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -26,6 +26,7 @@ class ShortUrlsParamsInputFilter extends InputFilter public const ORDER_BY = 'orderBy'; public const EXCLUDE_MAX_VISITS_REACHED = 'excludeMaxVisitsReached'; public const EXCLUDE_PAST_VALID_UNTIL = 'excludePastValidUntil'; + public const DOMAIN = 'domain'; public function __construct(array $data) { @@ -56,5 +57,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED)); $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL)); + + $this->add(InputFactory::basic(self::DOMAIN)); } } diff --git a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index ac3379df..4daa5cb9 100644 --- a/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -6,19 +6,19 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -/** @implements AdapterInterface */ +/** @implements AdapterInterface */ readonly class ShortUrlRepositoryAdapter implements AdapterInterface { public function __construct( private ShortUrlListRepositoryInterface $repository, private ShortUrlsParams $params, - private ?ApiKey $apiKey, + private ApiKey|null $apiKey, private string $defaultDomain, ) { } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php index 906adc63..a8e42236 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php @@ -17,14 +17,15 @@ class ShortUrlsCountFiltering public readonly bool $searchIncludesDefaultDomain; public function __construct( - public readonly ?string $searchTerm = null, + public readonly string|null $searchTerm = null, public readonly array $tags = [], - public readonly ?TagsMode $tagsMode = null, - public readonly ?DateRange $dateRange = null, + public readonly TagsMode|null $tagsMode = null, + public readonly DateRange|null $dateRange = null, public readonly bool $excludeMaxVisitsReached = false, public readonly bool $excludePastValidUntil = false, - public readonly ?ApiKey $apiKey = null, - ?string $defaultDomain = null, + public readonly ApiKey|null $apiKey = null, + string|null $defaultDomain = null, + public readonly string|null $domain = null, ) { $this->searchIncludesDefaultDomain = !empty($searchTerm) && !empty($defaultDomain) && str_contains( strtolower($defaultDomain), @@ -32,7 +33,7 @@ class ShortUrlsCountFiltering ); } - public static function fromParams(ShortUrlsParams $params, ?ApiKey $apiKey, string $defaultDomain): self + public static function fromParams(ShortUrlsParams $params, ApiKey|null $apiKey, string $defaultDomain): self { return new self( $params->searchTerm, @@ -43,6 +44,7 @@ class ShortUrlsCountFiltering $params->excludePastValidUntil, $apiKey, $defaultDomain, + $params->domain, ); } } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index 589947dd..d0fa6418 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -13,17 +13,19 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering { public function __construct( - public readonly ?int $limit = null, - public readonly ?int $offset = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, public readonly Ordering $orderBy = new Ordering(), - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?TagsMode $tagsMode = null, - ?DateRange $dateRange = null, + TagsMode|null $tagsMode = null, + DateRange|null $dateRange = null, bool $excludeMaxVisitsReached = false, bool $excludePastValidUntil = false, - ?ApiKey $apiKey = null, - ?string $defaultDomain = null, + ApiKey|null $apiKey = null, + // Used only to determine if search term includes default domain + string|null $defaultDomain = null, + string|null $domain = null, ) { parent::__construct( $searchTerm, @@ -34,6 +36,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $excludePastValidUntil, $apiKey, $defaultDomain, + $domain, ); } @@ -41,7 +44,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering int $limit, int $offset, ShortUrlsParams $params, - ?ApiKey $apiKey, + ApiKey|null $apiKey, string $defaultDomain, ): self { return new self( @@ -56,6 +59,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering $params->excludePastValidUntil, $apiKey, $defaultDomain, + $params->domain, ); } } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index e8fd4ac6..c18b31ef 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -9,9 +9,10 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -24,7 +25,7 @@ use function sprintf; class ShortUrlListRepository extends EntitySpecificationRepository implements ShortUrlListRepositoryInterface { /** - * @return ShortUrlWithVisitsSummary[] + * @return ShortUrlWithDeps[] */ public function findList(ShortUrlsListFiltering $filtering): array { @@ -43,7 +44,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $qb = $this->createListQueryBuilder($filtering); $qb->select( - 'DISTINCT s AS shortUrl', + 'DISTINCT s AS shortUrl, d.authority', '(' . $buildVisitsSubQuery('v', excludingBots: false) . ') AS ' . OrderableField::VISITS->value, '(' . $buildVisitsSubQuery('v2', excludingBots: true) . ') AS ' . OrderableField::NON_BOT_VISITS->value, // This is added only to have a consistent order by title between database engines @@ -56,9 +57,9 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $this->processOrderByForList($qb, $filtering); - /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string}[] $result */ + /** @var array{shortUrl: ShortUrl, visits: string, nonBotVisits: string, authority: string|null}[] $result */ $result = $qb->getQuery()->getResult(); - return map($result, static fn (array $s) => ShortUrlWithVisitsSummary::fromArray($s)); + return map($result, static fn (array $s) => ShortUrlWithDeps::fromArray($s)); } private function processOrderByForList(QueryBuilder $qb, ShortUrlsListFiltering $filtering): void @@ -89,6 +90,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') + ->leftJoin('s.domain', 'd') ->where('1=1'); $dateRange = $filtering->dateRange; @@ -103,14 +105,13 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $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 + // Apply search term to every "searchable" field $conditions = [ $qb->expr()->like('s.longUrl', ':searchPattern'), $qb->expr()->like('s.shortCode', ':searchPattern'), @@ -118,8 +119,8 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $qb->expr()->like('d.authority', ':searchPattern'), ]; - // Include default domain in search if provided - if ($filtering->searchIncludesDefaultDomain) { + // Include default domain in search if included, and a domain was not explicitly provided + if ($filtering->searchIncludesDefaultDomain && $filtering->domain === null) { $conditions[] = $qb->expr()->isNull('s.domain'); } @@ -129,8 +130,7 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); } - $qb->leftJoin('s.domain', 'd') - ->andWhere($qb->expr()->orX(...$conditions)) + $qb->andWhere($qb->expr()->orX(...$conditions)) ->setParameter('searchPattern', '%' . $searchTerm . '%'); } @@ -142,6 +142,15 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh : $this->joinAllTags($qb, $tags); } + if ($filtering->domain !== null) { + if ($filtering->domain === Domain::DEFAULT_AUTHORITY) { + $qb->andWhere($qb->expr()->isNull('s.domain')); + } else { + $qb->andWhere($qb->expr()->eq('d.authority', ':domain')) + ->setParameter('domain', $filtering->domain); + } + } + if ($filtering->excludeMaxVisitsReached) { $qb->andWhere($qb->expr()->orX( $qb->expr()->isNull('s.maxVisits'), diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php index db3f8017..d71f6297 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepositoryInterface.php @@ -4,14 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Repository; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; interface ShortUrlListRepositoryInterface { /** - * @return ShortUrlWithVisitsSummary[] + * @return ShortUrlWithDeps[] */ public function findList(ShortUrlsListFiltering $filtering): array; diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 015c8eac..bb6abea2 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -23,7 +23,7 @@ use function strtolower; /** @extends EntitySpecificationRepository */ class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ShortUrl|null { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at // the bottom @@ -52,7 +52,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl + public function findOne(ShortUrlIdentifier $identifier, Specification|null $spec = null): ShortUrl|null { $qb = $this->createFindOneQueryBuilder($identifier, $spec); $qb->select('s'); @@ -60,12 +60,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool { return $this->doShortCodeIsInUse($identifier, $spec, null); } - public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool { return $this->doShortCodeIsInUse($identifier, $spec, LockMode::PESSIMISTIC_WRITE); } @@ -73,8 +73,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU /** * @param LockMode::PESSIMISTIC_WRITE|null $lockMode */ - private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?LockMode $lockMode): bool - { + private function doShortCodeIsInUse( + ShortUrlIdentifier $identifier, + Specification|null $spec, + LockMode|null $lockMode, + ): bool { $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); $query = $qb->getQuery(); @@ -85,7 +88,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $query->getOneOrNullResult() !== null; } - private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, ?Specification $spec): QueryBuilder + private function createFindOneQueryBuilder(ShortUrlIdentifier $identifier, Specification|null $spec): QueryBuilder { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(ShortUrl::class, 's') @@ -101,7 +104,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl + public function findOneMatching(ShortUrlCreation $creation): ShortUrl|null { $qb = $this->getEntityManager()->createQueryBuilder(); @@ -166,7 +169,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU } } - public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl + public function findOneByImportedUrl(ImportedShlinkUrl $url): ShortUrl|null { $qb = $this->createQueryBuilder('s'); $qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) @@ -180,7 +183,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getOneOrNullResult(); } - private function whereDomainIs(QueryBuilder $qb, ?string $domain): void + private function whereDomainIs(QueryBuilder $qb, string|null $domain): void { if ($domain !== null) { $qb->join('s.domain', 'd') diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index d0934197..a96d0be8 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -16,15 +16,18 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; /** @extends ObjectRepository */ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl; + public function findOneWithDomainFallback( + ShortUrlIdentifier $identifier, + ShortUrlMode $shortUrlMode, + ): ShortUrl|null; - public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; + public function findOne(ShortUrlIdentifier $identifier, Specification|null $spec = null): ShortUrl|null; - public function shortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; + public function shortCodeIsInUse(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool; - public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; + public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, Specification|null $spec = null): bool; - public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl; + public function findOneMatching(ShortUrlCreation $creation): ShortUrl|null; - public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; + public function findOneByImportedUrl(ImportedShlinkUrl $url): ShortUrl|null; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 94fb314a..df578387 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -11,8 +11,8 @@ use Doctrine\ORM\Events; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Symfony\Component\Lock\Lock; use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Lock\Store\InMemoryStore; use function array_map; @@ -24,9 +24,9 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt private array $memoizedNewDomains = []; /** @var array */ private array $memoizedNewTags = []; - /** @var array */ + /** @var array */ private array $tagLocks = []; - /** @var array */ + /** @var array */ private array $domainLocks = []; public function __construct( @@ -38,7 +38,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt $this->em->getEventManager()->addEventListener(Events::postFlush, $this); } - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain|null { if ($domain === null || $domain === $this->options->defaultDomain) { return null; @@ -100,7 +100,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt } /** - * @param array $locks + * @param array $locks */ private function lock(array &$locks, string $name): void { @@ -112,7 +112,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** /** - * @param array $locks + * @param array $locks */ private function releaseLock(array &$locks, string $name): void { diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index b5228214..6af627b5 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Tag\Entity\Tag; interface ShortUrlRelationResolverInterface { - public function resolveDomain(?string $domain): ?Domain; + public function resolveDomain(string|null $domain): Domain|null; /** * @param string[] $tags diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index c1a9d0ab..5702c346 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -12,7 +12,7 @@ use function array_map; class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface { - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain|null { return $domain !== null ? Domain::withAuthority($domain) : null; } diff --git a/module/Core/src/ShortUrl/ShortUrlListService.php b/module/Core/src/ShortUrl/ShortUrlListService.php index 853a40b9..2a1adb26 100644 --- a/module/Core/src/ShortUrl/ShortUrlListService.php +++ b/module/Core/src/ShortUrl/ShortUrlListService.php @@ -22,7 +22,7 @@ readonly class ShortUrlListService implements ShortUrlListServiceInterface /** * @inheritDoc */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator + public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator { $defaultDomain = $this->urlShortenerOptions->defaultDomain; $paginator = new Paginator(new ShortUrlRepositoryAdapter($this->repo, $params, $apiKey, $defaultDomain)); diff --git a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php index b83abd4c..9ece5cad 100644 --- a/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlListServiceInterface.php @@ -6,13 +6,13 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Rest\Entity\ApiKey; interface ShortUrlListServiceInterface { /** - * @return Paginator + * @return Paginator */ - public function listShortUrls(ShortUrlsParams $params, ?ApiKey $apiKey = null): Paginator; + public function listShortUrls(ShortUrlsParams $params, ApiKey|null $apiKey = null): Paginator; } diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 14727ff5..408988a5 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -4,18 +4,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class ShortUrlResolver implements ShortUrlResolverInterface { public function __construct( - private EntityManagerInterface $em, + private ShortUrlRepositoryInterface $repo, private UrlShortenerOptions $urlShortenerOptions, ) { } @@ -23,11 +22,9 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl + public function resolveShortUrl(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): ShortUrl { - /** @var ShortUrlRepository $shortUrlRepo */ - $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOne($identifier, $apiKey?->spec()); + $shortUrl = $this->repo->findOne($identifier, $apiKey?->spec()); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } @@ -53,9 +50,7 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface */ public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl { - /** @var ShortUrlRepository $shortUrlRepo */ - $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); + $shortUrl = $this->repo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); if ($shortUrl === null) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/ShortUrl/ShortUrlResolverInterface.php b/module/Core/src/ShortUrl/ShortUrlResolverInterface.php index 9dd522c0..bcf7d40a 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolverInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlResolverInterface.php @@ -14,7 +14,7 @@ interface ShortUrlResolverInterface /** * @throws ShortUrlNotFoundException */ - public function resolveShortUrl(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): ShortUrl; + public function resolveShortUrl(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): ShortUrl; /** * Resolves a public short URL matching provided identifier. diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index d75f847d..b2c7e92f 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -29,7 +29,7 @@ readonly class ShortUrlService implements ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { $shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit); diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index c7892f55..fde21a70 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -18,6 +18,6 @@ interface ShortUrlServiceInterface public function updateShortUrl( ShortUrlIdentifier $identifier, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): ShortUrl; } diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php index 8ad6713f..eec122a2 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleter.php @@ -10,18 +10,18 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface +readonly class ShortUrlVisitsDeleter implements ShortUrlVisitsDeleterInterface { public function __construct( - private readonly VisitDeleterRepositoryInterface $repository, - private readonly ShortUrlResolverInterface $resolver, + private VisitDeleterRepositoryInterface $repository, + private ShortUrlResolverInterface $resolver, ) { } /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): BulkDeleteResult { $shortUrl = $this->resolver->resolveShortUrl($identifier, $apiKey); return new BulkDeleteResult($this->repository->deleteShortUrlVisits($shortUrl)); diff --git a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php index 46e9fde5..625880dc 100644 --- a/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlVisitsDeleterInterface.php @@ -14,5 +14,5 @@ interface ShortUrlVisitsDeleterInterface /** * @throws ShortUrlNotFoundException */ - public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ?ApiKey $apiKey = null): BulkDeleteResult; + public function deleteShortUrlVisits(ShortUrlIdentifier $identifier, ApiKey|null $apiKey = null): BulkDeleteResult; } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php index 3c95593c..42f9c722 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKey.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class BelongsToApiKey extends BaseSpecification { - public function __construct(private ApiKey $apiKey, ?string $context = null) + public function __construct(private ApiKey $apiKey, string|null $context = null) { parent::__construct($context); } diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php index 33eacec8..4a2aae62 100644 --- a/module/Core/src/ShortUrl/Spec/BelongsToDomain.php +++ b/module/Core/src/ShortUrl/Spec/BelongsToDomain.php @@ -10,7 +10,7 @@ use Happyr\DoctrineSpecification\Specification\BaseSpecification; class BelongsToDomain extends BaseSpecification { - public function __construct(private string $domainId, private ?string $dqlAlias = null) + public function __construct(private string $domainId, private string|null $dqlAlias = null) { parent::__construct(); } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index d2bdb73a..2692f76b 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -6,7 +6,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterface { @@ -14,12 +15,14 @@ readonly class ShortUrlDataTransformer implements ShortUrlDataTransformerInterfa { } - public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array + public function transform(ShortUrlWithDeps|ShortUrl $shortUrl): array { - $shortUrl = $data instanceof ShortUrlWithVisitsSummary ? $data->shortUrl : $data; + $shortUrlIdentifier = $shortUrl instanceof ShortUrl + ? ShortUrlIdentifier::fromShortUrl($shortUrl) + : $shortUrl->toIdentifier(); return [ - 'shortUrl' => $this->stringifier->stringify($shortUrl), - ...$data->toArray(), + 'shortUrl' => $this->stringifier->stringify($shortUrlIdentifier), + ...$shortUrl->toArray(), ]; } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php index e1101f70..cd8aeb37 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformerInterface.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; interface ShortUrlDataTransformerInterface { - public function transform(ShortUrlWithVisitsSummary|ShortUrl $data): array; + public function transform(ShortUrlWithDeps|ShortUrl $shortUrl): array; } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 4a908c78..2a4d7571 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -17,14 +17,15 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; -class UrlShortener implements UrlShortenerInterface +readonly class UrlShortener implements UrlShortenerInterface { public function __construct( - private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private readonly EntityManagerInterface $em, - private readonly ShortUrlRelationResolverInterface $relationResolver, - private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, - private readonly EventDispatcherInterface $eventDispatcher, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private EntityManagerInterface $em, + private ShortUrlRelationResolverInterface $relationResolver, + private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private EventDispatcherInterface $eventDispatcher, + private ShortUrlRepositoryInterface $repo, ) { } @@ -64,15 +65,13 @@ class UrlShortener implements UrlShortenerInterface return UrlShorteningResult::withoutErrorOnEventDispatching($newShortUrl); } - private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl + private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ShortUrl|null { if (! $creation->findIfExists) { return null; } - /** @var ShortUrlRepositoryInterface $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($creation); + return $this->repo->findOneMatching($creation); } private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 994e6d63..d373a59d 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Common\Util\DateRange; class InDateRange extends BaseSpecification { - public function __construct(private ?DateRange $dateRange, private string $field = 'date') + public function __construct(private DateRange|null $dateRange, private string $field = 'date') { parent::__construct(); } diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index 39092e4d..0b7a4272 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -11,7 +11,7 @@ enum OrderableField: string case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - public static function toValidField(?string $field): self + public static function toValidField(string|null $field): self { if ($field === null) { return self::TAG; diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 504181ec..dfa255bd 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -15,7 +15,7 @@ final readonly class TagInfo implements JsonSerializable public string $tag, public int $shortUrlsCount, int $visitsCount, - ?int $nonBotVisitsCount = null, + int|null $nonBotVisitsCount = null, ) { $this->visitsSummary = VisitsSummary::fromTotalAndNonBots($visitsCount, $nonBotVisitsCount ?? $visitsCount); } diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php index 236dde4a..d8da71b7 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -10,15 +10,15 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class TagsListFiltering { public function __construct( - public readonly ?int $limit = null, - public readonly ?int $offset = null, - public readonly ?string $searchTerm = null, - public readonly ?Ordering $orderBy = null, - public readonly ?ApiKey $apiKey = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, + public readonly string|null $searchTerm = null, + public readonly Ordering|null $orderBy = null, + public readonly ApiKey|null $apiKey = null, ) { } - public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self + public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ApiKey|null $apiKey): self { return new self($limit, $offset, $params->searchTerm, $params->orderBy, $apiKey); } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index d094bcc0..7207b1a8 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -12,10 +12,10 @@ use function Shlinkio\Shlink\Common\parseOrderBy; final class TagsParams extends AbstractInfinitePaginableListParams { private function __construct( - public readonly ?string $searchTerm, + public readonly string|null $searchTerm, public readonly Ordering $orderBy, - ?int $page, - ?int $itemsPerPage, + int|null $page, + int|null $itemsPerPage, ) { parent::__construct($page, $itemsPerPage); } diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index 98126e27..e26ba2f4 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -21,7 +21,7 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface public function __construct( protected TagRepositoryInterface $repo, protected TagsParams $params, - protected ?ApiKey $apiKey, + protected ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index a2820e7b..5f3eed11 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -42,10 +42,10 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito /** * @return TagInfo[] */ - public function findTagsWithInfo(?TagsListFiltering $filtering = null): array + public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array { $orderField = OrderableField::toValidField($filtering?->orderBy?->field); - $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; + $orderDir = $filtering->orderBy->direction ?? 'ASC'; $apiKey = $filtering?->apiKey; $conn = $this->getEntityManager()->getConnection(); @@ -113,8 +113,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->from('(' . $tagsSubQb->getSQL() . ')', 't') ->leftJoin('t', '(' . $allVisitsSubQb->getSQL() . ')', 'v', $mainQb->expr()->eq('t.tag_id', 'v.tag_id')) ->leftJoin('t', '(' . $nonBotVisitsSubQb->getSQL() . ')', 'b', $mainQb->expr()->eq('t.tag_id', 'b.tag_id')) - ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset ?? 0); + ->setMaxResults($filtering->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering->offset ?? 0); $mainQb->orderBy(camelCaseToSnakeCase($orderField->value), $orderDir); if ($orderField !== OrderableField::TAG) { @@ -134,7 +134,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ); } - public function tagExists(string $tag, ?ApiKey $apiKey = null): bool + public function tagExists(string $tag, ApiKey|null $apiKey = null): bool { $result = (int) $this->matchSingleScalarResult(Spec::andX( new CountTagsWithName($tag), diff --git a/module/Core/src/Tag/Repository/TagRepositoryInterface.php b/module/Core/src/Tag/Repository/TagRepositoryInterface.php index ccb33de0..b0601b3b 100644 --- a/module/Core/src/Tag/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Tag/Repository/TagRepositoryInterface.php @@ -4,22 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Repository; -use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\EntityRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Rest\Entity\ApiKey; -/** @extends ObjectRepository */ -interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface +/** @extends EntityRepositoryInterface */ +interface TagRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface { public function deleteByName(array $names): int; /** * @return TagInfo[] */ - public function findTagsWithInfo(?TagsListFiltering $filtering = null): array; + public function findTagsWithInfo(TagsListFiltering|null $filtering = null): array; - public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; + public function tagExists(string $tag, ApiKey|null $apiKey = null): bool; } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index de16fada..a2cbcf2c 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -10,39 +10,34 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; -use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class TagService implements TagServiceInterface { - public function __construct(private ORM\EntityManagerInterface $em) + public function __construct(private ORM\EntityManagerInterface $em, private TagRepositoryInterface $repo) { } /** * @inheritDoc */ - public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator + public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params); + return $this->createPaginator(new TagsPaginatorAdapter($this->repo, $params, $apiKey), $params); } /** * @inheritDoc */ - public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator + public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var TagRepositoryInterface $repo */ - $repo = $this->em->getRepository(Tag::class); - return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params); + return $this->createPaginator(new TagsInfoPaginatorAdapter($this->repo, $params, $apiKey), $params); } /** @@ -52,44 +47,40 @@ readonly class TagService implements TagServiceInterface */ private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator { - return (new Paginator($adapter)) - ->setMaxPerPage($params->itemsPerPage) - ->setCurrentPage($params->page); + $paginator = new Paginator($adapter); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); + + return $paginator; } /** * @inheritDoc */ - public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void + public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void { if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forDeletion(); } - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - $repo->deleteByName($tagNames); + $this->repo->deleteByName($tagNames); } /** * @inheritDoc */ - public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag + public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag { if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); } - /** @var TagRepository $repo */ - $repo = $this->em->getRepository(Tag::class); - - /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $renaming->oldName]); + $tag = $this->repo->findOneBy(['name' => $renaming->oldName]); if ($tag === null) { throw TagNotFoundException::fromTag($renaming->oldName); } - $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName]) > 0; + $newNameExists = $renaming->nameChanged() && $this->repo->count(['name' => $renaming->newName]) > 0; if ($newNameExists) { throw TagConflictException::forExistingTag($renaming); } diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index 60aeb7c7..a22e2ec8 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -8,9 +8,9 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -19,23 +19,23 @@ interface TagServiceInterface /** * @return Paginator */ - public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator; + public function listTags(TagsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator; + public function tagsInfo(TagsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @param string[] $tagNames * @throws ForbiddenTagOperationException */ - public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void; + public function deleteTags(array $tagNames, ApiKey|null $apiKey = null): void; /** * @throws TagNotFoundException * @throws TagConflictException * @throws ForbiddenTagOperationException */ - public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag; + public function renameTag(Renaming $renaming, ApiKey|null $apiKey = null): Tag; } diff --git a/module/Core/src/Util/IpAddressUtils.php b/module/Core/src/Util/IpAddressUtils.php index 66354c37..9adfa97d 100644 --- a/module/Core/src/Util/IpAddressUtils.php +++ b/module/Core/src/Util/IpAddressUtils.php @@ -56,7 +56,7 @@ final class IpAddressUtils * * @param string[] $ipAddressParts */ - private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface + private static function candidateToRange(string $candidate, array $ipAddressParts): RangeInterface|null { return str_contains($candidate, '*') ? self::parseValueWithWildcards($candidate, $ipAddressParts) @@ -68,7 +68,7 @@ final class IpAddressUtils * Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also * covers cases where wildcards are in between. */ - private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface + private static function parseValueWithWildcards(string $value, array $ipAddressParts): RangeInterface|null { $octets = explode('.', $value); $keys = array_keys($octets); diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index edb04b8e..4c4fdd21 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Core\Config\Options\RedirectOptions; use function sprintf; -class RedirectResponseHelper implements RedirectResponseHelperInterface +readonly class RedirectResponseHelper implements RedirectResponseHelperInterface { - public function __construct(private readonly RedirectOptions $options) + public function __construct(private RedirectOptions $options) { } diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index be8400dc..70733593 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -21,14 +21,15 @@ use function Shlinkio\Shlink\Core\normalizeDate; class Visit extends AbstractEntity implements JsonSerializable { private function __construct( - public readonly ?ShortUrl $shortUrl, + public readonly ShortUrl|null $shortUrl, public readonly VisitType $type, public readonly string $userAgent, public readonly string $referer, public readonly bool $potentialBot, - public readonly ?string $remoteAddr = null, - public readonly ?string $visitedUrl = null, - private ?VisitLocation $visitLocation = null, + public readonly string|null $remoteAddr = null, + public readonly string|null $visitedUrl = null, + public readonly string|null $redirectUrl = null, + private VisitLocation|null $visitLocation = null, public readonly Chronos $date = new Chronos(), ) { } @@ -53,20 +54,27 @@ class Visit extends AbstractEntity implements JsonSerializable return self::fromVisitor(null, VisitType::REGULAR_404, $visitor, $anonymize); } - private static function fromVisitor(?ShortUrl $shortUrl, VisitType $type, Visitor $visitor, bool $anonymize): self - { + private static function fromVisitor( + ShortUrl|null $shortUrl, + VisitType $type, + Visitor $visitor, + bool $anonymize, + ): self { + $geolocation = $visitor->geolocation; return new self( shortUrl: $shortUrl, type: $type, userAgent: $visitor->userAgent, referer: $visitor->referer, - potentialBot: $visitor->isPotentialBot(), + potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, + redirectUrl: $visitor->redirectUrl, + visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, ); } - private static function processAddress(?string $address, bool $anonymize): ?string + private static function processAddress(string|null $address, bool $anonymize): string|null { // Localhost address does not need to be anonymized if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) { @@ -96,7 +104,7 @@ class Visit extends AbstractEntity implements JsonSerializable private static function fromImportOrOrphanImport( ImportedShlinkVisit|ImportedShlinkOrphanVisit $importedVisit, VisitType $type, - ?ShortUrl $shortUrl = null, + ShortUrl|null $shortUrl = null, ): self { $importedLocation = $importedVisit->location; return new self( @@ -116,16 +124,11 @@ class Visit extends AbstractEntity implements JsonSerializable return ! empty($this->remoteAddr); } - public function getVisitLocation(): ?VisitLocation + public function getVisitLocation(): VisitLocation|null { return $this->visitLocation; } - public function isLocatable(): bool - { - return $this->hasRemoteAddr() && $this->remoteAddr !== IpAddress::LOCALHOST; - } - public function locate(VisitLocation $visitLocation): self { $this->visitLocation = $visitLocation; @@ -155,6 +158,7 @@ class Visit extends AbstractEntity implements JsonSerializable 'visitLocation' => $this->visitLocation, 'potentialBot' => $this->potentialBot, 'visitedUrl' => $this->visitedUrl, + 'redirectUrl' => $this->redirectUrl, ]; if (! $this->isOrphan()) { return $base; diff --git a/module/Core/src/Visit/Geolocation/VisitLocator.php b/module/Core/src/Visit/Geolocation/VisitLocator.php index 63cb6137..8f69ba2c 100644 --- a/module/Core/src/Visit/Geolocation/VisitLocator.php +++ b/module/Core/src/Visit/Geolocation/VisitLocator.php @@ -11,11 +11,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Repository\VisitIterationRepositoryInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; -class VisitLocator implements VisitLocatorInterface +readonly class VisitLocator implements VisitLocatorInterface { public function __construct( - private readonly EntityManagerInterface $em, - private readonly VisitIterationRepositoryInterface $repo, + private EntityManagerInterface $em, + private VisitIterationRepositoryInterface $repo, ) { } @@ -54,7 +54,7 @@ class VisitLocator implements VisitLocatorInterface } // If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again - $location = Location::emptyInstance(); + $location = Location::empty(); } $this->locateVisit($visit, VisitLocation::fromGeolocation($location), $helper); diff --git a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php index 9d614a7b..b444e592 100644 --- a/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php +++ b/module/Core/src/Visit/Geolocation/VisitToLocationHelper.php @@ -11,9 +11,9 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -class VisitToLocationHelper implements VisitToLocationHelperInterface +readonly class VisitToLocationHelper implements VisitToLocationHelperInterface { - public function __construct(private readonly IpLocationResolverInterface $ipLocationResolver) + public function __construct(private IpLocationResolverInterface $ipLocationResolver) { } diff --git a/module/Core/src/Visit/Model/OrphanVisitsParams.php b/module/Core/src/Visit/Model/OrphanVisitsParams.php index 0fb2e99b..6991928d 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -12,18 +12,17 @@ use function sprintf; final class OrphanVisitsParams extends VisitsParams { public function __construct( - ?DateRange $dateRange = null, - ?int $page = null, - ?int $itemsPerPage = null, + DateRange|null $dateRange = null, + int|null $page = null, + int|null $itemsPerPage = null, bool $excludeBots = false, - public readonly ?OrphanVisitType $type = null, + public readonly OrphanVisitType|null $type = null, ) { parent::__construct($dateRange, $page, $itemsPerPage, $excludeBots); } - public static function fromRawData(array $query): self + public static function fromVisitsParamsAndRawData(VisitsParams $visitsParams, array $query): self { - $visitsParams = parent::fromRawData($query); $type = $query['type'] ?? null; return new self( diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index ca5d79b2..cab834e6 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -6,78 +6,93 @@ namespace Shlinkio\Shlink\Core\Visit\Model; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; +use Shlinkio\Shlink\IpGeolocation\Model\Location; +use function Shlinkio\Shlink\Core\geolocationFromRequest; use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\isCrawler; use function substr; -final class Visitor +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + +final readonly class Visitor { public const USER_AGENT_MAX_LENGTH = 512; public const REFERER_MAX_LENGTH = 1024; public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; + public const REDIRECT_URL_MAX_LENGTH = 2048; - public readonly string $userAgent; - public readonly string $referer; - public readonly string $visitedUrl; - public readonly ?string $remoteAddress; - private bool $potentialBot; - - public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) - { - $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH); - $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH); - $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH); - $this->remoteAddress = $remoteAddress === null ? null : $this->cropToLength( - $remoteAddress, - self::REMOTE_ADDRESS_MAX_LENGTH, - ); - $this->potentialBot = isCrawler($userAgent); + private function __construct( + public string $userAgent, + public string $referer, + public string|null $remoteAddress, + public string $visitedUrl, + public bool $potentialBot, + public Location|null $geolocation, + public string|null $redirectUrl, + ) { } - private function cropToLength(string $value, int $length): string + public static function fromParams( + string $userAgent = '', + string $referer = '', + string|null $remoteAddress = null, + string $visitedUrl = '', + Location|null $geolocation = null, + string|null $redirectUrl = null, + ): self { + return new self( + userAgent: self::cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH), + referer: self::cropToLength($referer, self::REFERER_MAX_LENGTH), + remoteAddress: $remoteAddress === null + ? null + : self::cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH), + visitedUrl: self::cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH), + potentialBot: isCrawler($userAgent), + geolocation: $geolocation, + redirectUrl: $redirectUrl === null ? null : self::cropToLength($redirectUrl, self::REDIRECT_URL_MAX_LENGTH), + ); + } + + private static function cropToLength(string $value, int $length): string { return substr($value, 0, $length); } public static function fromRequest(ServerRequestInterface $request): self { - return new self( - $request->getHeaderLine('User-Agent'), - $request->getHeaderLine('Referer'), - ipAddressFromRequest($request), - $request->getUri()->__toString(), + return self::fromParams( + userAgent: $request->getHeaderLine('User-Agent'), + referer: $request->getHeaderLine('Referer'), + remoteAddress: ipAddressFromRequest($request), + visitedUrl: $request->getUri()->__toString(), + geolocation: geolocationFromRequest($request), + redirectUrl: $request->getAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE), ); } - public static function emptyInstance(): self + public static function empty(): self { - return new self('', '', null, ''); + return self::fromParams(); } public static function botInstance(): self { - return new self('cf-facebook', '', null, ''); - } - - public function isPotentialBot(): bool - { - return $this->potentialBot; + return self::fromParams(userAgent: 'cf-facebook'); } public function normalizeForTrackingOptions(TrackingOptions $options): self { - $instance = new self( - $options->disableUaTracking ? '' : $this->userAgent, - $options->disableReferrerTracking ? '' : $this->referer, - $options->disableIpTracking ? null : $this->remoteAddress, - $this->visitedUrl, + return new self( + userAgent: $options->disableUaTracking ? '' : $this->userAgent, + referer: $options->disableReferrerTracking ? '' : $this->referer, + remoteAddress: $options->disableIpTracking ? null : $this->remoteAddress, + visitedUrl: $this->visitedUrl, + // Keep the fact that the visit was a potential bot, even if we no longer save the user agent + potentialBot: $this->potentialBot, + geolocation: $this->geolocation, + redirectUrl: $this->redirectUrl, ); - - // Keep the fact that the visit was a potential bot, even if we no longer save the user agent - $instance->potentialBot = $this->potentialBot; - - return $instance; } } diff --git a/module/Core/src/Visit/Model/VisitsParams.php b/module/Core/src/Visit/Model/VisitsParams.php index 10713131..31e6e67d 100644 --- a/module/Core/src/Visit/Model/VisitsParams.php +++ b/module/Core/src/Visit/Model/VisitsParams.php @@ -14,9 +14,9 @@ class VisitsParams extends AbstractInfinitePaginableListParams public readonly DateRange $dateRange; public function __construct( - ?DateRange $dateRange = null, - ?int $page = null, - ?int $itemsPerPage = null, + DateRange|null $dateRange = null, + int|null $page = null, + int|null $itemsPerPage = null, public readonly bool $excludeBots = false, ) { parent::__construct($page, $itemsPerPage); diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 22f05bd4..2f812aef 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -14,8 +14,8 @@ final readonly class VisitsStats implements JsonSerializable public function __construct( int $nonOrphanVisitsTotal, int $orphanVisitsTotal, - ?int $nonOrphanVisitsNonBots = null, - ?int $orphanVisitsNonBots = null, + int|null $nonOrphanVisitsNonBots = null, + int|null $orphanVisitsNonBots = null, ) { $this->nonOrphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( $nonOrphanVisitsTotal, diff --git a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php index 330d8692..184ecdd1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/DomainVisitsPaginatorAdapter.php @@ -21,7 +21,7 @@ class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte private readonly VisitRepositoryInterface $visitRepository, private readonly string $domain, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php index 7929bcfd..5e3cdbe1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php @@ -18,7 +18,7 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda public function __construct( private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 9deedf9a..899ab831 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -18,7 +18,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte public function __construct( private readonly VisitRepositoryInterface $repo, private readonly OrphanVisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 43bc02ff..efd68035 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -20,7 +20,7 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap private readonly VisitRepositoryInterface $visitRepository, private readonly ShortUrlIdentifier $identifier, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 93a182bd..909bd2ba 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -19,7 +19,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter private readonly VisitRepositoryInterface $visitRepository, private readonly string $tag, private readonly VisitsParams $params, - private readonly ?ApiKey $apiKey, + private readonly ApiKey|null $apiKey, ) { } diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php index 88676df8..c09bc5ca 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php @@ -11,10 +11,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsCountFiltering extends VisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - public readonly ?OrphanVisitType $type = null, + ApiKey|null $apiKey = null, + public readonly OrphanVisitType|null $type = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php index c2873cdf..d1e49605 100644 --- a/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php @@ -11,12 +11,12 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class OrphanVisitsListFiltering extends OrphanVisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - ?OrphanVisitType $type = null, - public readonly ?int $limit = null, - public readonly ?int $offset = null, + ApiKey|null $apiKey = null, + OrphanVisitType|null $type = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey, $type); } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index 570abc19..8948c960 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsCountFiltering { public function __construct( - public readonly ?DateRange $dateRange = null, + public readonly DateRange|null $dateRange = null, public readonly bool $excludeBots = false, - public readonly ?ApiKey $apiKey = null, + public readonly ApiKey|null $apiKey = null, ) { } } diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index 747a3ce0..eded82eb 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -10,11 +10,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class VisitsListFiltering extends VisitsCountFiltering { public function __construct( - ?DateRange $dateRange = null, + DateRange|null $dateRange = null, bool $excludeBots = false, - ?ApiKey $apiKey = null, - public readonly ?int $limit = null, - public readonly ?int $offset = null, + ApiKey|null $apiKey = null, + public readonly int|null $limit = null, + public readonly int|null $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } diff --git a/module/Core/src/Visit/Repository/VisitDeleterRepository.php b/module/Core/src/Visit/Repository/VisitDeleterRepository.php index 94c57091..425dd88a 100644 --- a/module/Core/src/Visit/Repository/VisitDeleterRepository.php +++ b/module/Core/src/Visit/Repository/VisitDeleterRepository.php @@ -6,15 +6,28 @@ namespace Shlinkio\Shlink\Core\Visit\Repository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; /** @extends EntitySpecificationRepository */ class VisitDeleterRepository extends EntitySpecificationRepository implements VisitDeleterRepositoryInterface { public function deleteShortUrlVisits(ShortUrl $shortUrl): int + { + return $this->getEntityManager()->wrapInTransaction(function () use ($shortUrl): int { + $this->deleteByShortUrl(ShortUrlVisitsCount::class, $shortUrl); + return $this->deleteByShortUrl(Visit::class, $shortUrl); + }); + } + + /** + * @param class-string $entityName + */ + private function deleteByShortUrl(string $entityName, ShortUrl $shortUrl): int { $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->delete(Visit::class, 'v') + $qb->delete($entityName, 'v') ->where($qb->expr()->eq('v.shortUrl', ':shortUrl')) ->setParameter('shortUrl', $shortUrl); @@ -23,10 +36,15 @@ class VisitDeleterRepository extends EntitySpecificationRepository implements Vi public function deleteOrphanVisits(): int { - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->delete(Visit::class, 'v') - ->where($qb->expr()->isNull('v.shortUrl')); + $em = $this->getEntityManager(); + return $em->wrapInTransaction(function () use ($em): int { + $em->createQueryBuilder()->delete(OrphanVisitsCount::class, 'v')->getQuery()->execute(); - return $qb->getQuery()->execute(); + $qb = $em->createQueryBuilder(); + $qb->delete(Visit::class, 'v') + ->where($qb->expr()->isNull('v.shortUrl')); + + return $qb->getQuery()->execute(); + }); } } diff --git a/module/Core/src/Visit/Repository/VisitIterationRepository.php b/module/Core/src/Visit/Repository/VisitIterationRepository.php index 71590d7e..1370ed20 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepository.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepository.php @@ -48,7 +48,7 @@ class VisitIterationRepository extends EntitySpecificationRepository implements /** * @return iterable */ - public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable + public function findAllVisits(DateRange|null $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable { $qb = $this->createQueryBuilder('v'); if ($dateRange?->startDate !== null) { diff --git a/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php index d4ffb864..2f416324 100644 --- a/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitIterationRepositoryInterface.php @@ -24,5 +24,8 @@ interface VisitIterationRepositoryInterface /** * @return iterable */ - public function findAllVisits(?DateRange $dateRange = null, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable; + public function findAllVisits( + DateRange|null $dateRange = null, + 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 0708a4e1..9c4668c1 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -8,9 +8,10 @@ use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; @@ -47,7 +48,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering, ): QueryBuilder { - /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey?->spec())?->getId() ?? '-1'; @@ -124,7 +125,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->join('v.shortUrl', 's'); - if ($domain === 'DEFAULT') { + if ($domain === Domain::DEFAULT_AUTHORITY) { $qb->where($qb->expr()->isNull('s.domain')); } else { $qb->join('s.domain', 'd') @@ -202,7 +203,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $qb; } - private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void + private function applyDatesInline(QueryBuilder $qb, DateRange|null $dateRange): void { $conn = $this->getEntityManager()->getConnection(); @@ -214,7 +215,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } } - private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array + private function resolveVisitsWithNativeQuery(QueryBuilder $qb, int|null $limit, int|null $offset): array { // TODO Order by date and ID, not just by ID (order by date DESC, id DESC). // That ensures imported visits are properly ordered even if inserted in wrong chronological order. @@ -247,7 +248,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(); } - public function findMostRecentOrphanVisit(): ?Visit + public function findMostRecentOrphanVisit(): Visit|null { $dql = <<shouldTrackRequest($request)) { - $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); - } - } - - public function trackNotFoundIfApplicable(ServerRequestInterface $request): void + public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): Visit|null { if (! $this->shouldTrackRequest($request)) { - return; + return null; + } + + return $this->visitsTracker->track($shortUrl, Visitor::fromRequest($request)); + } + + public function trackNotFoundIfApplicable(ServerRequestInterface $request): Visit|null + { + if (! $this->shouldTrackRequest($request)) { + return null; } /** @var NotFoundType|null $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); $visitor = Visitor::fromRequest($request); - match (true) { + return match (true) { $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor), $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor), $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor), @@ -63,7 +66,7 @@ readonly class RequestTracker implements RequestTrackerInterface, RequestMethodI return ! $this->trackingOptions->queryHasDisableTrackParam($query); } - private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool + private function shouldDisableTrackingFromAddress(string|null $remoteAddr): bool { if ($remoteAddr === null || ! $this->trackingOptions->hasDisableTrackingFrom()) { return false; diff --git a/module/Core/src/Visit/RequestTrackerInterface.php b/module/Core/src/Visit/RequestTrackerInterface.php index 9048b07f..4fb159b0 100644 --- a/module/Core/src/Visit/RequestTrackerInterface.php +++ b/module/Core/src/Visit/RequestTrackerInterface.php @@ -6,10 +6,11 @@ namespace Shlinkio\Shlink\Core\Visit; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; interface RequestTrackerInterface { - public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void; + public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): Visit|null; - public function trackNotFoundIfApplicable(ServerRequestInterface $request): void; + public function trackNotFoundIfApplicable(ServerRequestInterface $request): Visit|null; } diff --git a/module/Core/src/Visit/VisitsDeleter.php b/module/Core/src/Visit/VisitsDeleter.php index 2b925e17..42ca0ffa 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -9,13 +9,13 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class VisitsDeleter implements VisitsDeleterInterface +readonly class VisitsDeleter implements VisitsDeleterInterface { - public function __construct(private readonly VisitDeleterRepositoryInterface $repository) + public function __construct(private VisitDeleterRepositoryInterface $repository) { } - public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult + public function deleteOrphanVisits(ApiKey|null $apiKey = null): BulkDeleteResult { $affectedItems = $apiKey?->hasRole(Role::NO_ORPHAN_VISITS) ? 0 : $this->repository->deleteOrphanVisits(); return new BulkDeleteResult($affectedItems); diff --git a/module/Core/src/Visit/VisitsDeleterInterface.php b/module/Core/src/Visit/VisitsDeleterInterface.php index 3a75a0d3..67fa5e72 100644 --- a/module/Core/src/Visit/VisitsDeleterInterface.php +++ b/module/Core/src/Visit/VisitsDeleterInterface.php @@ -9,5 +9,5 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsDeleterInterface { - public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult; + public function deleteOrphanVisits(ApiKey|null $apiKey = null): BulkDeleteResult; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 7f3e2282..412decc7 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; @@ -32,7 +32,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; readonly class VisitsStatsHelper implements VisitsStatsHelperInterface @@ -41,7 +41,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface { } - public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats + public function getVisitsStats(ApiKey|null $apiKey = null): VisitsStats { /** @var OrphanVisitsCountRepository $orphanVisitsCountRepo */ $orphanVisitsCountRepo = $this->em->getRepository(OrphanVisitsCount::class); @@ -68,15 +68,15 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Paginator { - /** @var ShortUrlRepositoryInterface $repo */ + /** @var ShortUrlRepository $repo */ $repo = $this->em->getRepository(ShortUrl::class); if (! $repo->shortCodeIsInUse($identifier, $apiKey?->spec())) { throw ShortUrlNotFoundException::fromNotFound($identifier); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator( @@ -88,7 +88,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var TagRepository $tagRepo */ $tagRepo = $this->em->getRepository(Tag::class); @@ -96,7 +96,7 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface throw TagNotFoundException::fromTag($tag); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); @@ -105,15 +105,15 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|null $apiKey = null): Paginator { /** @var DomainRepository $domainRepo */ $domainRepo = $this->em->getRepository(Domain::class); - if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) { + if ($domain !== Domain::DEFAULT_AUTHORITY && ! $domainRepo->domainExists($domain, $apiKey)) { throw DomainNotFoundException::fromAuthority($domain); } - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); @@ -122,17 +122,17 @@ readonly class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @inheritDoc */ - public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); } - public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator { - /** @var VisitRepositoryInterface $repo */ + /** @var VisitRepository $repo */ $repo = $this->em->getRepository(Visit::class); return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 87e0980b..12e58933 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; interface VisitsStatsHelperInterface { - public function getVisitsStats(?ApiKey $apiKey = null): VisitsStats; + public function getVisitsStats(ApiKey|null $apiKey = null): VisitsStats; /** * @return Paginator @@ -26,28 +26,28 @@ interface VisitsStatsHelperInterface public function visitsForShortUrl( ShortUrlIdentifier $identifier, VisitsParams $params, - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): Paginator; /** * @return Paginator * @throws TagNotFoundException */ - public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function visitsForTag(string $tag, VisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator * @throws DomainNotFoundException */ - public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function visitsForDomain(string $domain, VisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function orphanVisits(OrphanVisitsParams $params, ApiKey|null $apiKey = null): Paginator; /** * @return Paginator */ - public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function nonOrphanVisits(VisitsParams $params, ApiKey|null $apiKey = null): Paginator; } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index 85085220..1d33bbd8 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -21,65 +21,63 @@ readonly class VisitsTracker implements VisitsTrackerInterface ) { } - public function track(ShortUrl $shortUrl, Visitor $visitor): void + public function track(ShortUrl $shortUrl, Visitor $visitor): Visit|null { - $this->trackVisit( + return $this->trackVisit( fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackInvalidShortUrlVisit(Visitor $visitor): void + public function trackInvalidShortUrlVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackBaseUrlVisit(Visitor $visitor): void + public function trackBaseUrlVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - public function trackRegularNotFoundVisit(Visitor $visitor): void + public function trackRegularNotFoundVisit(Visitor $visitor): Visit|null { - $this->trackOrphanVisit( + return $this->trackOrphanVisit( fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr), $visitor, ); } - private function trackOrphanVisit(callable $createVisit, Visitor $visitor): void + private function trackOrphanVisit(callable $createVisit, Visitor $visitor): Visit|null { if (! $this->options->trackOrphanVisits) { - return; + return null; } - $this->trackVisit($createVisit, $visitor); + return $this->trackVisit($createVisit, $visitor); } /** * @param callable(Visitor $visitor): Visit $createVisit */ - private function trackVisit(callable $createVisit, Visitor $visitor): void + private function trackVisit(callable $createVisit, Visitor $visitor): Visit|null { if ($this->options->disableTracking) { - return; + return null; } $visit = $createVisit($visitor->normalizeForTrackingOptions($this->options)); - // Wrap persisting and flushing the visit in a transaction, so that the ShortUrlVisitsCountTracker performs - // changes inside that very same transaction atomically - $this->em->wrapInTransaction(function () use ($visit): void { - $this->em->persist($visit); - $this->em->flush(); - }); - + // Wrap persisting the visit in a transaction, so that the ShortUrlVisitsCountTracker performs changes inside + // that very same transaction atomically + $this->em->wrapInTransaction(fn () => $this->em->persist($visit)); $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); + + return $visit; } } diff --git a/module/Core/src/Visit/VisitsTrackerInterface.php b/module/Core/src/Visit/VisitsTrackerInterface.php index dc650326..da2eae84 100644 --- a/module/Core/src/Visit/VisitsTrackerInterface.php +++ b/module/Core/src/Visit/VisitsTrackerInterface.php @@ -5,15 +5,16 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; interface VisitsTrackerInterface { - public function track(ShortUrl $shortUrl, Visitor $visitor): void; + public function track(ShortUrl $shortUrl, Visitor $visitor): Visit|null; - public function trackInvalidShortUrlVisit(Visitor $visitor): void; + public function trackInvalidShortUrlVisit(Visitor $visitor): Visit|null; - public function trackBaseUrlVisit(Visitor $visitor): void; + public function trackBaseUrlVisit(Visitor $visitor): Visit|null; - public function trackRegularNotFoundVisit(Visitor $visitor): void; + public function trackRegularNotFoundVisit(Visitor $visitor): Visit|null; } diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index 36031da8..79a13fbf 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -89,8 +89,8 @@ class RedirectTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; - $clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php'; - foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) { + $ipAddressConfig = require __DIR__ . '/../../../../config/autoload/ip-address.global.php'; + foreach ($ipAddressConfig['rka']['ip_address']['headers_to_inspect'] as $header) { yield sprintf('rule: IP address in "%s" header', $header) => [ [ RequestOptions::HEADERS => [$header => '1.2.3.4'], diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 58817f38..ebb53c10 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -128,7 +128,7 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertFalse($this->repo->domainExists('foo.com', $detachedWithRedirectsApiKey)); } - private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl + private function createShortUrl(Domain $domain, ApiKey|null $apiKey = null): ShortUrl { return ShortUrl::create( ShortUrlCreation::fromRawData( @@ -139,7 +139,7 @@ class DomainRepositoryTest extends DatabaseTestCase { } - public function resolveDomain(?string $domain): ?Domain + public function resolveDomain(string|null $domain): Domain { return $this->domain; } diff --git a/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php index d630520b..60955dd1 100644 --- a/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/CrawlableShortCodesQueryTest.php @@ -16,8 +16,7 @@ class CrawlableShortCodesQueryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->query = new CrawlableShortCodesQuery($em, $em->getClassMetadata(ShortUrl::class)); + $this->query = $this->createRepository(ShortUrl::class, CrawlableShortCodesQuery::class); } #[Test] diff --git a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php index d90ad256..2e10d935 100644 --- a/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/DeleteExpiredShortUrlsRepositoryTest.php @@ -22,8 +22,7 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repository = new ExpiredShortUrlsRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->repository = $this->createRepository(ShortUrl::class, ExpiredShortUrlsRepository::class); } #[Test] @@ -93,7 +92,7 @@ class DeleteExpiredShortUrlsRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); for ($j = 0; $j < $visitsPerShortUrl; $j++) { - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::empty())); } } } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php index 95924956..435c3e58 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlListRepositoryTest.php @@ -9,11 +9,12 @@ use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\Attributes\Test; 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\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithVisitsSummary; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -36,7 +37,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase protected function setUp(): void { $em = $this->getEntityManager(); - $this->repo = new ShortUrlListRepository($em, $em->getClassMetadata(ShortUrl::class)); + $this->repo = $this->createRepository(ShortUrl::class, ShortUrlListRepository::class); $this->relationResolver = new PersistenceShortUrlRelationResolver($em); } @@ -73,7 +74,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $foo2 = ShortUrl::withLongUrl('https://foo_2'); $visits2 = array_map(function () use ($foo2) { - $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($foo2, Visitor::empty()); $this->getEntityManager()->persist($visit); return $visit; @@ -96,7 +97,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $result = $this->repo->findList(new ShortUrlsListFiltering(searchTerm: 'bar')); self::assertCount(2, $result); self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); - self::assertContains($foo, map($result, fn (ShortUrlWithVisitsSummary $s) => $s->shortUrl)); + self::assertContains($foo, map($result, fn (ShortUrlWithDeps $s) => $s->shortUrl)); $result = $this->repo->findList(new ShortUrlsListFiltering()); self::assertCount(3, $result); @@ -261,16 +262,23 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $buildFiltering = static fn (string $searchTerm) => new ShortUrlsListFiltering( + $buildFiltering = static fn (string $searchTerm = '', string|null $domain = null) => new ShortUrlsListFiltering( searchTerm: $searchTerm, defaultDomain: 'deFaulT-domain.com', + domain: $domain, ); - 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'))); + self::assertCount(2, $this->repo->findList($buildFiltering(searchTerm: 'default-dom'))); + self::assertCount(2, $this->repo->findList($buildFiltering(searchTerm: 'DOM'))); + self::assertCount(1, $this->repo->findList($buildFiltering(searchTerm: 'another'))); + self::assertCount(3, $this->repo->findList($buildFiltering(searchTerm: 'foo'))); + self::assertCount(0, $this->repo->findList($buildFiltering(searchTerm: 'no results'))); + self::assertCount(1, $this->repo->findList($buildFiltering(domain: 'another.com'))); + self::assertCount(0, $this->repo->findList($buildFiltering( + searchTerm: 'default-domain.com', + domain: 'another.com', + ))); + self::assertCount(2, $this->repo->findList($buildFiltering(domain: Domain::DEFAULT_AUTHORITY))); } #[Test] @@ -296,25 +304,49 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase '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()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl4, Visitor::empty())); $this->getEntityManager()->flush(); $filtering = static fn (bool $excludeMaxVisitsReached, bool $excludePastValidUntil) => - new ShortUrlsListFiltering( - excludeMaxVisitsReached: $excludeMaxVisitsReached, - excludePastValidUntil: $excludePastValidUntil, - ); + new ShortUrlsListFiltering( + 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))); + self::assertCount(4, $this->repo->findList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: false, + ))); + self::assertEquals(4, $this->repo->countList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: false, + ))); + self::assertCount(3, $this->repo->findList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: false, + ))); + self::assertEquals(3, $this->repo->countList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: false, + ))); + self::assertCount(3, $this->repo->findList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: true, + ))); + self::assertEquals(3, $this->repo->countList($filtering( + excludeMaxVisitsReached: false, + excludePastValidUntil: true, + ))); + self::assertCount(2, $this->repo->findList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: true, + ))); + self::assertEquals(2, $this->repo->countList($filtering( + excludeMaxVisitsReached: true, + excludePastValidUntil: true, + ))); } } diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index 074acdd4..535ca50f 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -404,7 +404,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase #[Test] public function importedShortUrlsAreFoundWhenExpected(): void { - $buildImported = static fn (string $shortCode, ?string $domain = null) => + $buildImported = static fn (string $shortCode, string|null $domain = null) => new ImportedShlinkUrl(ImportSource::BITLY, 'https://foo', [], Chronos::now(), $domain, $shortCode, null); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index f88a8e7f..b7027f97 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -29,8 +29,8 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase */ #[Test, DataProvider('provideFilters')] public function expectedListOfTagsIsReturned( - ?string $searchTerm, - ?string $orderBy, + string|null $searchTerm, + string|null $orderBy, int $offset, int $length, array $expectedTags, diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 77f6aa6a..224e0c11 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -57,7 +57,7 @@ class TagRepositoryTest extends DatabaseTestCase } #[Test, DataProvider('provideFilters')] - public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void + public function properTagsInfoIsReturned(TagsListFiltering|null $filtering, array $expectedList): void { $names = ['foo', 'bar', 'baz', 'another']; foreach ($names as $name) { @@ -73,19 +73,19 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($names, 3); $secondUrlTags = [$names[0]]; - $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( + $metaWithTags = static fn (array $tags, ApiKey|null $apiKey) => ShortUrlCreation::fromRawData( ['longUrl' => 'https://longUrl', 'tags' => $tags, 'apiKey' => $apiKey], ); $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::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::empty())); $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::botInstance())); $shortUrl2 = ShortUrl::create($metaWithTags($secondUrlTags, null), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); // One of the tags has two extra short URLs, but with no visits $this->getEntityManager()->persist( diff --git a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php index ad8edcd2..34eeb9f9 100644 --- a/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/OrphanVisitsCountTrackerTest.php @@ -26,7 +26,7 @@ class OrphanVisitsCountTrackerTest extends DatabaseTestCase #[Test] public function createsNewEntriesWhenNoneExist(): void { - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); @@ -47,7 +47,7 @@ class OrphanVisitsCountTrackerTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $visit = Visit::forRegularNotFound(Visitor::emptyInstance()); + $visit = Visit::forRegularNotFound(Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php index 7a2a6c29..7a4c4d18 100644 --- a/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php +++ b/module/Core/test-db/Visit/Listener/ShortUrlVisitsCountTrackerTest.php @@ -30,7 +30,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); @@ -54,7 +54,7 @@ class ShortUrlVisitsCountTrackerTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); $this->getEntityManager()->persist($visit); $this->getEntityManager()->flush(); diff --git a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php index 62aa89e7..58d52713 100644 --- a/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitDeleterRepositoryTest.php @@ -9,19 +9,26 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; +use Shlinkio\Shlink\Core\Visit\Entity\OrphanVisitsCount; +use Shlinkio\Shlink\Core\Visit\Entity\ShortUrlVisitsCount; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Repository\OrphanVisitsCountRepository; +use Shlinkio\Shlink\Core\Visit\Repository\ShortUrlVisitsCountRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepository; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; class VisitDeleterRepositoryTest extends DatabaseTestCase { private VisitDeleterRepository $repo; + private ShortUrlVisitsCountRepository $visitsCountRepo; + private OrphanVisitsCountRepository $orphanVisitsCountRepo; protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repo = new VisitDeleterRepository($em, $em->getClassMetadata(Visit::class)); + $this->repo = $this->createRepository(Visit::class, VisitDeleterRepository::class); + $this->visitsCountRepo = $this->getEntityManager()->getRepository(ShortUrlVisitsCount::class); + $this->orphanVisitsCountRepo = $this->getEntityManager()->getRepository(OrphanVisitsCount::class); } #[Test] @@ -29,8 +36,8 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase { $shortUrl1 = ShortUrl::withLongUrl('https://foo.com'); $this->getEntityManager()->persist($shortUrl1); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl1, Visitor::empty())); $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://foo.com', @@ -38,32 +45,40 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase ShortUrlInputFilter::CUSTOM_SLUG => 'foo', ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); $this->getEntityManager()->persist($shortUrl2); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::empty())); $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://foo.com', ShortUrlInputFilter::CUSTOM_SLUG => 'foo', ]), new PersistenceShortUrlRelationResolver($this->getEntityManager())); $this->getEntityManager()->persist($shortUrl3); - $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl3, Visitor::empty())); $this->getEntityManager()->flush(); - self::assertEquals(2, $this->repo->deleteShortUrlVisits($shortUrl1)); - self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl1)); - self::assertEquals(4, $this->repo->deleteShortUrlVisits($shortUrl2)); - self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl2)); - self::assertEquals(1, $this->repo->deleteShortUrlVisits($shortUrl3)); - self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl3)); + $this->assertVisitsDeletionForShortUrl($shortUrl1, 2); + $this->assertVisitsDeletionForShortUrl($shortUrl2, 4); + $this->assertVisitsDeletionForShortUrl($shortUrl3, 1); + } + + private function assertVisitsDeletionForShortUrl(ShortUrl $shortUrl, int $expectedDeleteCount): void + { + // There should be at least one visit count before deletion + self::assertGreaterThan(0, $this->visitsCountRepo->count(['shortUrl' => $shortUrl])); + self::assertEquals($expectedDeleteCount, $this->repo->deleteShortUrlVisits($shortUrl)); + + // Visits counts are also deleted, and trying to delete again results in no visits deleted + self::assertEquals(0, $this->visitsCountRepo->count(['shortUrl' => $shortUrl])); + self::assertEquals(0, $this->repo->deleteShortUrlVisits($shortUrl)); } #[Test] public function deletesExpectedOrphanVisits(): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $this->getEntityManager()->persist(Visit::forBasePath($visitor)); $this->getEntityManager()->persist(Visit::forInvalidShortUrl($visitor)); $this->getEntityManager()->persist(Visit::forRegularNotFound($visitor)); @@ -73,7 +88,9 @@ class VisitDeleterRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + self::assertGreaterThan(0, $this->orphanVisitsCountRepo->count()); self::assertEquals(6, $this->repo->deleteOrphanVisits()); self::assertEquals(0, $this->repo->deleteOrphanVisits()); + self::assertEquals(0, $this->orphanVisitsCountRepo->count()); } } diff --git a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php index 6d3d4b39..60c2fbea 100644 --- a/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitIterationRepositoryTest.php @@ -25,8 +25,7 @@ class VisitIterationRepositoryTest extends DatabaseTestCase protected function setUp(): void { - $em = $this->getEntityManager(); - $this->repo = new VisitIterationRepository($em, $em->getClassMetadata(Visit::class)); + $this->repo = $this->createRepository(Visit::class, VisitIterationRepository::class); } #[Test, DataProvider('provideBlockSize')] @@ -38,7 +37,7 @@ class VisitIterationRepositoryTest extends DatabaseTestCase $unmodifiedDate = Chronos::now(); for ($i = 0; $i < 6; $i++) { Chronos::setTestNow($unmodifiedDate->subDays($i)); // Enforce a different day for every visit - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); if ($i >= 2) { $location = VisitLocation::fromGeolocation(Location::emptyInstance()); diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 9dc18390..29227b8b 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -227,7 +227,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); - self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertCount(6, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering())); self::assertCount(3, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering())); self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering(null, true))); self::assertCount(2, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( @@ -236,10 +236,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); - self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertCount(2, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertCount(4, $this->repo->findVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -251,7 +251,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); - self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertEquals(6, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering())); self::assertEquals(3, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering())); self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering(null, true))); self::assertEquals(2, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( @@ -260,10 +260,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(1, $this->repo->countVisitsByDomain('s.test', new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); - self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertEquals(2, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + self::assertEquals(4, $this->repo->countVisitsByDomain(Domain::DEFAULT_AUTHORITY, new VisitsListFiltering( DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -311,9 +311,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($domainApiKey); // Visits not linked to any short URL - $this->getEntityManager()->persist(Visit::forBasePath(Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::emptyInstance())); - $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::emptyInstance())); + $this->getEntityManager()->persist(Visit::forBasePath(Visitor::empty())); + $this->getEntityManager()->persist(Visit::forInvalidShortUrl(Visitor::empty())); + $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::empty())); $this->getEntityManager()->persist(Visit::forRegularNotFound(Visitor::botInstance())); $this->getEntityManager()->flush(); @@ -370,15 +370,15 @@ class VisitRepositoryTest extends DatabaseTestCase $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forBasePath($botsCount < 1 ? Visitor::emptyInstance() : Visitor::botInstance()), + fn () => Visit::forBasePath($botsCount < 1 ? Visitor::empty() : Visitor::botInstance()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); @@ -428,15 +428,15 @@ class VisitRepositoryTest extends DatabaseTestCase for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forBasePath(Visitor::emptyInstance()), + fn () => Visit::forBasePath(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forInvalidShortUrl(Visitor::emptyInstance()), + fn () => Visit::forInvalidShortUrl(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); $this->getEntityManager()->persist($this->setDateOnVisit( - fn () => Visit::forRegularNotFound(Visitor::emptyInstance()), + fn () => Visit::forRegularNotFound(Visitor::empty()), Chronos::parse(sprintf('2020-01-0%s', $i + 1)), )); } @@ -515,7 +515,7 @@ class VisitRepositoryTest extends DatabaseTestCase { $this->assertNull($this->repo->findMostRecentOrphanVisit()); - $lastVisit = Visit::forBasePath(Visitor::emptyInstance()); + $lastVisit = Visit::forBasePath(Visitor::empty()); $this->getEntityManager()->persist($lastVisit); $this->getEntityManager()->flush(); @@ -534,7 +534,7 @@ class VisitRepositoryTest extends DatabaseTestCase private function createShortUrlsAndVisits( bool|string $withDomain = true, array $tags = [], - ?ApiKey $apiKey = null, + ApiKey|null $apiKey = null, ): array { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => 'https://longUrl', @@ -567,7 +567,7 @@ class VisitRepositoryTest extends DatabaseTestCase $visit = $this->setDateOnVisit( fn () => Visit::forValidShortUrl( $shortUrl, - $botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(), + $botsAmount < 1 ? Visitor::empty() : Visitor::botInstance(), ), Chronos::parse(sprintf('2016-01-%s', str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT)))->startOfDay(), ); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index d6f2566a..e78df177 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class PixelActionTest extends TestCase { private PixelAction $action; @@ -34,12 +36,17 @@ class PixelActionTest extends TestCase public function imageIsReturned(): void { $shortCode = 'abc123'; + $shortUrl = ShortUrl::withLongUrl('http://domain.com/foo/bar'); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')); - $this->requestTracker->expects($this->once())->method('trackIfApplicable')->withAnyParameters(); + )->willReturn($shortUrl); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, null), + ); - $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->createMock(RequestHandlerInterface::class)); self::assertInstanceOf(PixelResponse::class, $response); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 5e499403..f8dea217 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -190,7 +190,7 @@ class QrCodeActionTest extends TestCase #[Test, DataProvider('provideRoundBlockSize')] public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( QrCodeOptions $defaultOptions, - ?string $roundBlockSize, + string|null $roundBlockSize, int $expectedColor, ): void { $code = 'abc123'; @@ -234,7 +234,7 @@ class QrCodeActionTest extends TestCase } #[Test, DataProvider('provideColors')] - public function properColorsAreUsed(?string $queryColor, ?string $optionsColor, int $expectedColor): void + public function properColorsAreUsed(string|null $queryColor, string|null $optionsColor, int $expectedColor): void { $code = 'abc123'; $req = ServerRequestFactory::fromGlobals() @@ -320,7 +320,7 @@ class QrCodeActionTest extends TestCase yield 'only enabled short URLs' => [false]; } - public function action(?QrCodeOptions $options = null): QrCodeAction + public function action(QrCodeOptions|null $options = null): QrCodeAction { return new QrCodeAction( $this->urlResolver, diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 2364371c..fa4a561d 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -19,6 +19,8 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class RedirectActionTest extends TestCase { private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; @@ -50,16 +52,20 @@ class RedirectActionTest extends TestCase { $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); + $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); + $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn($shortUrl); - $this->requestTracker->expects($this->once())->method('trackIfApplicable'); - $expectedResp = new Response\RedirectResponse(self::LONG_URL); + $this->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, self::LONG_URL), + ); $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->createMock(RequestHandlerInterface::class)); self::assertSame($expectedResp, $response); diff --git a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php index 80d5203a..39e37f32 100644 --- a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php +++ b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php @@ -23,14 +23,14 @@ class ShortUrlMethodsProcessorTest extends TestCase #[Test, DataProvider('provideConfigs')] public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods( array $config, - ?array $expectedRoutes, + array|null $expectedRoutes, ): void { self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null); } public static function provideConfigs(): iterable { - $buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[ + $buildConfigWithStatus = static fn (int $status, array|null $expectedAllowedMethods) => [[ 'routes' => [ ['name' => 'foo'], ['name' => 'bar'], diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index 7e2fea18..fb601d51 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -25,19 +25,23 @@ class DomainServiceTest extends TestCase { private DomainService $domainService; private MockObject & EntityManagerInterface $em; + private MockObject & DomainRepositoryInterface $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->domainService = new DomainService($this->em, new UrlShortenerOptions(defaultDomain: 'default.com')); + $this->repo = $this->createMock(DomainRepositoryInterface::class); + $this->domainService = new DomainService( + $this->em, + new UrlShortenerOptions(defaultDomain: 'default.com'), + $this->repo, + ); } #[Test, DataProvider('provideExcludedDomains')] - public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void + public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ApiKey|null $apiKey): void { - $repo = $this->createMock(DomainRepository::class); - $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); $result = $this->domainService->listDomains($apiKey); @@ -124,14 +128,12 @@ class DomainServiceTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void + public function getOrCreateAlwaysPersistsDomain(Domain|null $foundDomain, ApiKey|null $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( + $this->repo->expects($this->once())->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'); @@ -149,9 +151,7 @@ class DomainServiceTest extends TestCase $domain = Domain::withAuthority($authority); $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); $this->em->expects($this->never())->method('persist'); $this->em->expects($this->never())->method('flush'); @@ -161,12 +161,14 @@ class DomainServiceTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void - { + public function configureNotFoundRedirectsConfiguresFetchedDomain( + Domain|null $foundDomain, + ApiKey|null $apiKey, + ): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepository::class); - $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); - $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); + $this->repo->expects($this->once())->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( + $foundDomain, + ); $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); $this->em->expects($this->once())->method('flush'); diff --git a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php index 4558197b..9df12a6d 100644 --- a/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTrackerMiddlewareTest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ErrorHandler; +use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,6 +16,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTrackerMiddleware; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class NotFoundTrackerMiddlewareTest extends TestCase { private NotFoundTrackerMiddleware $middleware; @@ -33,12 +37,22 @@ class NotFoundTrackerMiddlewareTest extends TestCase ); } - #[Test] - public function delegatesIntoRequestTracker(): void + #[Test, DataProvider('provideResponses')] + public function delegatesIntoRequestTracker(Response $resp, string|null $expectedRedirectUrl): void { - $this->handler->expects($this->once())->method('handle')->with($this->request); - $this->requestTracker->expects($this->once())->method('trackNotFoundIfApplicable')->with($this->request); + $this->handler->expects($this->once())->method('handle')->with($this->request)->willReturn($resp); + $this->requestTracker->expects($this->once())->method('trackNotFoundIfApplicable')->with( + $this->request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, $expectedRedirectUrl), + ); - $this->middleware->process($this->request, $this->handler); + $result = $this->middleware->process($this->request, $this->handler); + + self::assertSame($resp, $result); + } + + public static function provideResponses(): iterable + { + yield 'no location response' => [new Response(), null]; + yield 'location response' => [new Response\RedirectResponse('the_location'), 'the_location']; } } diff --git a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php index 0dda17b0..ef776d42 100644 --- a/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php +++ b/module/Core/test/EventDispatcher/LocateUnlocatedVisitsTest.php @@ -39,7 +39,7 @@ class LocateUnlocatedVisitsTest extends TestCase #[Test] public function visitToLocationHelperIsCalledToGeolocateVisits(): void { - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $location = Location::emptyInstance(); $this->visitToLocation->expects($this->once())->method('resolveVisitLocation')->with($visit)->willReturn( diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php deleted file mode 100644 index 63595a6c..00000000 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ /dev/null @@ -1,195 +0,0 @@ -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, - $this->em, - $this->logger, - $this->dbUpdater, - $this->eventDispatcher, - ); - } - - #[Test] - public function invalidVisitLogsWarning(): void - { - $event = new UrlVisited('123'); - $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); - } - - #[Test] - public function nonExistingGeoLiteDbLogsWarning(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), - ); - $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], - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); - - ($this->locateVisit)($event); - } - - #[Test] - public function invalidAddressLogsWarning(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), - ); - $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}', - $this->isType('array'), - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - - ($this->locateVisit)($event); - } - - #[Test] - public function unhandledExceptionLogsError(): void - { - $event = new UrlVisited('123'); - $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), - ); - $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}', - $this->isType('array'), - ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); - - ($this->locateVisit)($event); - } - - #[Test, DataProvider('provideNonLocatableVisits')] - public function nonLocatableVisitsResolveToEmptyLocations(Visit $visit): void - { - $event = new UrlVisited('123'); - $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())); - } - - public static function provideNonLocatableVisits(): iterable - { - $shortUrl = ShortUrl::createFake(); - - yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; - yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; - yield 'localhost' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', IpAddress::LOCALHOST, ''))]; - } - - #[Test, DataProvider('provideIpAddresses')] - public function locatableVisitsResolveToLocation(Visit $visit, ?string $originalIpAddress): void - { - $ipAddr = $originalIpAddress ?? $visit->remoteAddr; - $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = new UrlVisited('123', $originalIpAddress); - - $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', $originalIpAddress), - ); - $this->logger->expects($this->never())->method('warning'); - - ($this->locateVisit)($event); - - self::assertEquals($visit->getVisitLocation(), VisitLocation::fromGeolocation($location)); - } - - public static function provideIpAddresses(): iterable - { - yield 'no original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), - null, - ]; - yield 'original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), - '1.2.3.4', - ]; - yield 'base url' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; - yield 'invalid short url' => [Visit::forInvalidShortUrl(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; - yield 'regular not found' => [Visit::forRegularNotFound(new Visitor('', '', '1.2.3.4', '')), '1.2.3.4']; - } -} diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php index 10726273..8a4c1b7d 100644 --- a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; @@ -39,7 +39,7 @@ class SendVisitToMatomoTest extends TestCase $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); - ($this->listener(enabled: false))(new VisitLocated('123')); + ($this->listener(enabled: false))(new UrlVisited('123')); } #[Test] @@ -53,21 +53,21 @@ class SendVisitToMatomoTest extends TestCase ['visitId' => '123'], ); - ($this->listener())(new VisitLocated('123')); + ($this->listener())(new UrlVisited('123')); } #[Test, DataProvider('provideOriginalIpAddress')] - public function visitIsSentWhenItExists(?string $originalIpAddress): void + public function visitIsSentWhenItExists(string|null $originalIpAddress): void { $visitId = '123'; - $visit = Visit::forBasePath(Visitor::emptyInstance()); + $visit = Visit::forBasePath(Visitor::empty()); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); $this->visitSender->expects($this->once())->method('sendVisit')->with($visit, $originalIpAddress); $this->logger->expects($this->never())->method('error'); $this->logger->expects($this->never())->method('warning'); - ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); + ($this->listener())(new UrlVisited($visitId, $originalIpAddress)); } public static function provideOriginalIpAddress(): iterable @@ -92,7 +92,7 @@ class SendVisitToMatomoTest extends TestCase ['e' => $e], ); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } private function listener(bool $enabled = true): SendVisitToMatomo diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index aa21411e..91569c9b 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -54,14 +54,14 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); $this->helper->expects($this->never())->method('publishUpdate'); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } #[Test] public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); @@ -74,14 +74,14 @@ class NotifyVisitToMercureTest extends TestCase $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)); + ($this->listener)(new UrlVisited($visitId)); } #[Test] public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()); $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); @@ -98,7 +98,7 @@ class NotifyVisitToMercureTest extends TestCase $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)); + ($this->listener)(new UrlVisited($visitId)); } #[Test, DataProvider('provideOrphanVisits')] @@ -117,12 +117,12 @@ class NotifyVisitToMercureTest extends TestCase $this->updatesGenerator->expects($this->never())->method('newVisitUpdate'); $this->helper->expects($this->once())->method('publishUpdate')->with($update); - ($this->listener)(new VisitLocated($visitId)); + ($this->listener)(new UrlVisited($visitId)); } public static function provideOrphanVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 1ea76bf6..310c8b3f 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -41,14 +41,14 @@ class PublishingUpdatesGeneratorTest extends TestCase } #[Test, DataProvider('provideMethod')] - public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, ?string $title): void + public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic, string|null $title): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', 'longUrl' => 'https://longUrl', 'title' => $title, ])); - $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl($shortUrl, Visitor::empty()); /** @var Update $update */ $update = $this->generator->{$method}($visit); @@ -71,6 +71,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), + 'hasRedirectRules' => false, ], 'visit' => [ 'referer' => '', @@ -79,6 +80,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'date' => $visit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => '', + 'redirectUrl' => null, ], ], $update->payload); } @@ -104,13 +106,14 @@ class PublishingUpdatesGeneratorTest extends TestCase 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl, 'type' => $orphanVisit->type->value, + 'redirectUrl' => null, ], ], $update->payload); } public static function provideOrphanVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; @@ -145,6 +148,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'crawlable' => false, 'forwardQuery' => true, 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), + 'hasRedirectRules' => false, ]], $update->payload); } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index ac744824..a3cebaa7 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -17,7 +17,7 @@ use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Config\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -52,7 +52,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); - ($this->listener(new RabbitMqOptions(enabled: false)))(new VisitLocated('123')); + ($this->listener(new RabbitMqOptions(enabled: false)))(new UrlVisited('123')); } #[Test] @@ -67,7 +67,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->logger->expects($this->never())->method('debug'); $this->helper->expects($this->never())->method('publishUpdate'); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } #[Test, DataProvider('provideVisits')] @@ -85,12 +85,12 @@ class NotifyVisitToRabbitMqTest extends TestCase ); $this->logger->expects($this->never())->method('debug'); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function provideVisits(): iterable { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']]; yield 'non-orphan visit' => [ @@ -110,7 +110,7 @@ class NotifyVisitToRabbitMqTest extends TestCase { $visitId = '123'; $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), ); $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( $this->isInstanceOf(Visit::class), @@ -121,7 +121,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ['e' => $e, 'name' => 'RabbitMQ'], ); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function provideExceptions(): iterable @@ -142,7 +142,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $setup($this->updatesGenerator); $expect($this->helper, $this->updatesGenerator); - ($this->listener())(new VisitLocated($visitId)); + ($this->listener())(new UrlVisited($visitId)); } public static function providePayloads(): iterable @@ -152,7 +152,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $never = static fn () => $exactly(0); yield 'non-orphan visit' => [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::empty()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($never())->method('newOrphanVisitUpdate'); @@ -166,7 +166,7 @@ class NotifyVisitToRabbitMqTest extends TestCase }, ]; yield 'orphan visit' => [ - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator) use ($once, $never): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($once())->method('newOrphanVisitUpdate')->willReturn($update); @@ -179,7 +179,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ]; } - private function listener(?RabbitMqOptions $options = null): NotifyVisitToRabbitMq + private function listener(RabbitMqOptions|null $options = null): NotifyVisitToRabbitMq { return new NotifyVisitToRabbitMq( $this->helper, diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php index cbccffd7..7cbf68b6 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -15,7 +15,7 @@ use Psr\Log\LoggerInterface; use RuntimeException; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; use Shlinkio\Shlink\Common\UpdatePublishing\Update; -use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; +use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -45,7 +45,7 @@ class NotifyVisitToRedisTest extends TestCase $this->logger->expects($this->never())->method('warning'); $this->logger->expects($this->never())->method('debug'); - $this->createListener(false)(new VisitLocated('123')); + $this->createListener(false)(new UrlVisited('123')); } #[Test, DataProvider('provideExceptions')] @@ -53,7 +53,7 @@ class NotifyVisitToRedisTest extends TestCase { $visitId = '123'; $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), + Visit::forBasePath(Visitor::empty()), ); $this->updatesGenerator->expects($this->once())->method('newOrphanVisitUpdate')->with( $this->isInstanceOf(Visit::class), @@ -64,7 +64,7 @@ class NotifyVisitToRedisTest extends TestCase ['e' => $e, 'name' => 'Redis pub/sub'], ); - $this->createListener()(new VisitLocated($visitId)); + $this->createListener()(new UrlVisited($visitId)); } public static function provideExceptions(): iterable diff --git a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php index dc604521..3b20ab0c 100644 --- a/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php +++ b/module/Core/test/EventDispatcher/UpdateGeoLiteDbTest.php @@ -77,7 +77,7 @@ class UpdateGeoLiteDbTest extends TestCase int $total, int $downloaded, bool $oldDbExists, - ?string $expectedMessage, + string|null $expectedMessage, ): void { $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( function ($_, callable $secondCallback) use ($total, $downloaded, $oldDbExists): GeolocationResult { diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index c1e0d158..84ec48eb 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; class NonUniqueSlugExceptionTest extends TestCase { #[Test, DataProvider('provideMessages')] - public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, ?string $domain): void + public function properlyCreatesExceptionFromSlug(string $expectedMessage, string $slug, string|null $domain): void { $expectedAdditional = ['customSlug' => $slug]; if ($domain !== null) { diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index aee8a29f..62e0afa2 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -16,7 +16,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase public function properlyCreatesExceptionFromNotFoundShortCode( string $expectedMessage, string $shortCode, - ?string $domain, + string|null $domain, ): void { $expectedAdditional = ['shortCode' => $shortCode]; if ($domain !== null) { diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index 2f4bd66a..9126e6f3 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Exception; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\TagConflictException; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use function sprintf; @@ -19,7 +19,7 @@ class TagConflictExceptionTest extends TestCase $oldName = 'foo'; $newName = 'bar'; $expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName); - $e = TagConflictException::forExistingTag(TagRenaming::fromNames($oldName, $newName)); + $e = TagConflictException::forExistingTag(Renaming::fromNames($oldName, $newName)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 5bb3baa8..3cb87250 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -20,7 +20,7 @@ use function print_r; class ValidationExceptionTest extends TestCase { #[Test, DataProvider('provideExceptions')] - public function createsExceptionFromInputFilter(?Throwable $prev): void + public function createsExceptionFromInputFilter(Throwable|null $prev): void { $invalidData = [ 'foo' => 'bar', diff --git a/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php new file mode 100644 index 00000000..210fb46f --- /dev/null +++ b/module/Core/test/Geolocation/Middleware/IpGeolocationMiddlewareTest.php @@ -0,0 +1,170 @@ +ipLocationResolver = $this->createMock(IpLocationResolverInterface::class); + $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->handler = $this->createMock(RequestHandlerInterface::class); + } + + #[Test] + public function geolocationIsSkippedIfTrackingIsDisabled(): void + { + $this->dbUpdater->expects($this->never())->method('databaseFileExists'); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals(); + $this->handler->expects($this->once())->method('handle')->with($request); + + $this->middleware(disableTracking: true)->process($request, $this->handler); + } + + #[Test] + public function warningIsLoggedIfGeoLiteDbDoesNotExist(): void + { + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to geolocate IP address, but a GeoLite2 db was not found.', + ); + + $request = ServerRequestFactory::fromGlobals(); + $this->handler->expects($this->once())->method('handle')->with($request); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + #[TestWith([null])] + #[TestWith([IpAddress::LOCALHOST])] + public function emptyLocationIsReturnedIfIpAddressIsNotLocatable(string|null $ipAddress): void + { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver->expects($this->never())->method('resolveIpLocation'); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, $ipAddress); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEmpty($location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + public function locationIsResolvedFromIpAddress(): void + { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->with('1.2.3.4')->willReturn( + new Location(countryCode: 'ES'), + ); + $this->logger->expects($this->never())->method('warning'); + + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEquals('ES', $location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + #[Test] + #[TestWith([ + new WrongIpException(), + 'warning', + 'Tried to locate IP address, but it seems to be wrong. {e}', + ])] + #[TestWith([ + new RuntimeException('Unknown'), + 'error', + 'An unexpected error occurred while trying to locate IP address. {e}', + ])] + public function warningIsPrintedIfAnErrorOccurs( + Throwable $exception, + string $loggerMethod, + string $expectedLoggedMessage, + ): void { + $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); + $this->ipLocationResolver + ->expects($this->once()) + ->method('resolveIpLocation') + ->with('1.2.3.4') + ->willThrowException($exception); + $this->logger->expects($this->once())->method($loggerMethod)->with($expectedLoggedMessage, ['e' => $exception]); + + $request = ServerRequestFactory::fromGlobals()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'); + $this->handler->expects($this->once())->method('handle')->with($this->callback( + function (ServerRequestInterface $req): bool { + $location = $req->getAttribute(Location::class); + if (! $location instanceof Location) { + return false; + } + + Assert::assertEmpty($location->countryCode); + return true; + }, + )); + + $this->middleware()->process($request, $this->handler); + } + + private function middleware(bool $disableTracking = false): IpGeolocationMiddleware + { + return new IpGeolocationMiddleware( + $this->ipLocationResolver, + $this->dbUpdater, + $this->logger, + new TrackingOptions(disableTracking: $disableTracking), + ); + } +} diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index a1816563..36265aa3 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -127,7 +127,7 @@ class ImportedLinksProcessorTest extends TestCase $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( + fn (ImportedShlinkUrl $url): ShortUrl|null => contains( $url->longUrl, ['https://foo', 'https://baz2', 'https://baz3'], ) ? ShortUrl::fromImport($url, true) : null, @@ -175,7 +175,7 @@ class ImportedLinksProcessorTest extends TestCase ImportedShlinkUrl $importedUrl, string $expectedOutput, int $amountOfPersistedVisits, - ?ShortUrl $foundShortUrl, + ShortUrl|null $foundShortUrl, ): void { $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($foundShortUrl); @@ -232,7 +232,7 @@ class ImportedLinksProcessorTest extends TestCase } #[Test, DataProvider('provideFoundShortUrls')] - public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ?ShortUrl $foundShortUrl): void + public function visitsArePersistedWithProperShortUrl(ShortUrl $originalShortUrl, ShortUrl|null $foundShortUrl): void { $this->em->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->repo->expects($this->once())->method('findOneByImportedUrl')->willReturn($originalShortUrl); @@ -273,7 +273,7 @@ class ImportedLinksProcessorTest extends TestCase public function properAmountOfOrphanVisitsIsImported( bool $importOrphanVisits, iterable $visits, - ?Visit $lastOrphanVisit, + Visit|null $lastOrphanVisit, int $expectedImportedVisits, ): void { $this->io->expects($this->exactly($importOrphanVisits ? 2 : 1))->method('title'); diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php index 1b55405e..e0bd0fde 100644 --- a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -43,7 +43,7 @@ class MatomoTrackerBuilderTest extends TestCase self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); } - private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + private function builder(MatomoOptions|null $options = null): MatomoTrackerBuilder { $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); return new MatomoTrackerBuilder($options); diff --git a/module/Core/test/Matomo/MatomoVisitSenderTest.php b/module/Core/test/Matomo/MatomoVisitSenderTest.php index 6a4659f1..0acccd1d 100644 --- a/module/Core/test/Matomo/MatomoVisitSenderTest.php +++ b/module/Core/test/Matomo/MatomoVisitSenderTest.php @@ -43,7 +43,7 @@ class MatomoVisitSenderTest extends TestCase } #[Test, DataProvider('provideTrackerMethods')] - public function visitIsSentToMatomo(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + public function visitIsSentToMatomo(Visit $visit, string|null $originalIpAddress, array $invokedMethods): void { $tracker = $this->createMock(MatomoTracker::class); $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); @@ -77,9 +77,9 @@ class MatomoVisitSenderTest extends TestCase public static function provideTrackerMethods(): iterable { - yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::empty()), null, []]; yield 'located regular visit' => [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::empty()) ->locate(VisitLocation::fromGeolocation(new Location( countryCode: 'countryCode', countryName: 'countryName', @@ -92,7 +92,7 @@ class MatomoVisitSenderTest extends TestCase '1.2.3.4', ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], ]; - yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + yield 'fallback IP' => [Visit::forBasePath(Visitor::fromParams(remoteAddress: '1.2.3.4')), null, ['setIp']]; } #[Test, DataProvider('provideUrlsToTrack')] @@ -115,9 +115,9 @@ class MatomoVisitSenderTest extends TestCase public static function provideUrlsToTrack(): iterable { - yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::empty()), '']; yield 'orphan visit with visited URL' => [ - Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + Visit::forBasePath(Visitor::fromParams(visitedUrl: 'https://s.test/foo')), 'https://s.test/foo', ]; yield 'non-orphan visit' => [ @@ -126,7 +126,7 @@ class MatomoVisitSenderTest extends TestCase ShortUrlInputFilter::LONG_URL => 'https://shlink.io', ShortUrlInputFilter::CUSTOM_SLUG => 'bar', ]), - ), Visitor::emptyInstance()), + ), Visitor::empty()), 'http://s2.test/bar', ]; } @@ -135,7 +135,7 @@ class MatomoVisitSenderTest extends TestCase public function multipleVisitsCanBeSent(): void { $dateRange = DateRange::allTime(); - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $bot = Visitor::botInstance(); $this->visitIterationRepository->expects($this->once())->method('findAllVisits')->with($dateRange)->willReturn([ diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 3cd44ef0..5a4a2e2b 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -3,13 +3,15 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; +use Shlinkio\Shlink\IpGeolocation\Model\Location; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; use const ShlinkioTest\Shlink\IOS_USER_AGENT; @@ -42,7 +44,7 @@ class RedirectConditionTest extends TestCase #[TestWith(['en-UK', 'en', true], 'only lang')] #[TestWith(['es-AR', 'en', false], 'different only lang')] #[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')] - public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void + public function matchesLanguage(string|null $acceptLanguage, string $value, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($acceptLanguage !== null) { @@ -62,7 +64,7 @@ class RedirectConditionTest extends TestCase #[TestWith([IOS_USER_AGENT, DeviceType::IOS, true])] #[TestWith([IOS_USER_AGENT, DeviceType::ANDROID, false])] #[TestWith([DESKTOP_USER_AGENT, DeviceType::IOS, false])] - public function matchesDevice(?string $userAgent, DeviceType $value, bool $expected): void + public function matchesDevice(string|null $userAgent, DeviceType $value, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($userAgent !== null) { @@ -82,15 +84,53 @@ class RedirectConditionTest extends TestCase #[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')] #[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')] #[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')] - public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void + public function matchesRemoteIpAddress(string|null $remoteIp, string $ipToMatch, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); if ($remoteIp !== null) { - $request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp); + $request = $request->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, $remoteIp); } $result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request); self::assertEquals($expected, $result); } + + #[Test, DataProvider('provideVisitsWithCountry')] + public function matchesGeolocationCountryCode( + Location|null $location, + string $countryCodeToMatch, + bool $expected, + ): void { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCountryCode($countryCodeToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisitsWithCountry(): iterable + { + yield 'no location' => [null, 'US', false]; + yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; + yield 'matching location' => [new Location(countryCode: 'US'), 'US', true]; + yield 'matching case-insensitive' => [new Location(countryCode: 'US'), 'us', true]; + } + + #[Test, DataProvider('provideVisitsWithCity')] + public function matchesGeolocationCityName( + Location|null $location, + string $cityNameToMatch, + bool $expected, + ): void { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCityName($cityNameToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisitsWithCity(): iterable + { + yield 'no location' => [null, 'New York', false]; + yield 'non-matching location' => [new Location(city: 'Los Angeles'), 'New York', false]; + yield 'matching location' => [new Location(city: 'Madrid'), 'Madrid', true]; + yield 'matching case-insensitive' => [new Location(city: 'Los Angeles'), 'los angeles', true]; + } } diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php index e71140cb..d0186faa 100644 --- a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -63,6 +63,18 @@ class RedirectRulesDataTest extends TestCase ], ], ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'not an country code', + ], + ], + ], + ]]])] public function throwsWhenProvidedDataIsInvalid(array $invalidData): void { $this->expectException(ValidationException::class); @@ -118,6 +130,18 @@ class RedirectRulesDataTest extends TestCase ], ], ]]], 'in-between IP wildcard pattern')] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'US', + ], + ], + ], + ]]], 'country code')] public function allowsValidDataToBeSet(array $validData): void { $result = RedirectRulesData::fromRawData($validData); diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php index 103c6fd0..47aa6490 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -99,8 +99,6 @@ class ShortUrlRedirectRuleServiceTest extends TestCase $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); self::assertCount(2, $result); - self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]); - self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]); } #[Test] diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index 3bf23863..470ff95e 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; @@ -18,6 +17,7 @@ use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; use const ShlinkioTest\Shlink\IOS_USER_AGENT; @@ -36,7 +36,7 @@ class ShortUrlRedirectionResolverTest extends TestCase #[Test, DataProvider('provideData')] public function resolveLongUrlReturnsExpectedValue( ServerRequestInterface $request, - ?RedirectCondition $condition, + RedirectCondition|null $condition, string $expectedUrl, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ @@ -90,22 +90,22 @@ class ShortUrlRedirectionResolverTest extends TestCase 'https://example.com/from-rule', ]; yield 'matching static IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4'), RedirectCondition::forIpAddress('1.2.3.4'), 'https://example.com/from-rule', ]; yield 'matching CIDR block' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '192.168.1.35'), RedirectCondition::forIpAddress('192.168.1.0/24'), 'https://example.com/from-rule', ]; yield 'matching wildcard IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.5.5'), RedirectCondition::forIpAddress('1.2.*.*'), 'https://example.com/from-rule', ]; yield 'non-matching IP address' => [ - $request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'), + $request()->withAttribute(IP_ADDRESS_REQUEST_ATTRIBUTE, '4.3.2.1'), RedirectCondition::forIpAddress('1.2.3.4'), 'https://example.com/foo/bar', ]; diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index faddafeb..73feece2 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -34,7 +34,7 @@ class DeleteShortUrlServiceTest extends TestCase protected function setUp(): void { $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection( - array_map(fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), range(0, 10)), + array_map(fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 10)), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index b3720254..29e9d88c 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -75,7 +75,7 @@ class ShortUrlTest extends TestCase } #[Test, DataProvider('provideLengths')] - public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void + public function shortCodesHaveExpectedLength(int|null $length, int $expectedLength): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'https://longUrl'], @@ -94,7 +94,7 @@ class ShortUrlTest extends TestCase #[TestWith([null, '', 5])] #[TestWith(['foo bar/', 'foo-bar-', 13])] public function shortCodesHaveExpectedPrefix( - ?string $pathPrefix, + string|null $pathPrefix, string $expectedPrefix, int $expectedShortCodeLength, ): void { diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index de1402f6..c08a95af 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; -use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -14,38 +13,34 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; class ShortCodeUniquenessHelperTest extends TestCase { private ShortCodeUniquenessHelper $helper; - private MockObject & EntityManagerInterface $em; + private MockObject & ShortUrlRepositoryInterface $repo; private MockObject & ShortUrl $shortUrl; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em, new UrlShortenerOptions()); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->helper = new ShortCodeUniquenessHelper($this->repo, new UrlShortenerOptions()); $this->shortUrl = $this->createMock(ShortUrl::class); $this->shortUrl->method('getShortCode')->willReturn('abc123'); } #[Test, DataProvider('provideDomains')] - public function shortCodeIsRegeneratedIfAlreadyInUse(?Domain $domain, ?string $expectedAuthority): void + public function shortCodeIsRegeneratedIfAlreadyInUse(Domain|null $domain, string|null $expectedAuthority): void { $callIndex = 0; $expectedCalls = 3; - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->exactly($expectedCalls))->method('shortCodeIsInUseWithLock')->with( + $this->repo->expects($this->exactly($expectedCalls))->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123', $expectedAuthority), )->willReturnCallback(function () use (&$callIndex, $expectedCalls) { $callIndex++; return $callIndex < $expectedCalls; }); - $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(); @@ -63,11 +58,9 @@ class ShortCodeUniquenessHelperTest extends TestCase #[Test] public function inUseSlugReturnsError(): void { - $repo = $this->createMock(ShortUrlRepository::class); - $repo->expects($this->once())->method('shortCodeIsInUseWithLock')->with( + $this->repo->expects($this->once())->method('shortCodeIsInUseWithLock')->with( ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), )->willReturn(true); - $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'); diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index d1283a78..6f48a836 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -35,8 +35,8 @@ class ShortUrlRedirectionBuilderTest extends TestCase public function buildShortUrlRedirectBuildsExpectedUrl( string $expectedUrl, ServerRequestInterface $request, - ?string $extraPath, - ?bool $forwardQuery, + string|null $extraPath, + bool|null $forwardQuery, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://example.com/foo/bar?some=thing', diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index d28fdf0e..03799e10 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -32,7 +32,7 @@ class ShortUrlStringifierTest extends TestCase public static function provideConfigAndShortUrls(): iterable { - $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( + $shortUrlWithShortCode = fn (string $shortCode, string|null $domain = null) => ShortUrl::create( ShortUrlCreation::fromRawData([ 'longUrl' => 'https://longUrl', 'customSlug' => $shortCode, diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index b5b8e00c..d73a1a6d 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -90,8 +90,8 @@ class ShortUrlTitleResolutionHelperTest extends TestCase } #[Test] - #[TestWith(['TEXT/html; charset=utf-8'], name: 'charset')] - #[TestWith(['TEXT/html'], name: 'no charset')] + #[TestWith(['TEXT/html; charset=utf-8'], 'charset')] + #[TestWith(['TEXT/html'], 'no charset')] public function titleIsUpdatedWhenItCanBeResolvedFromResponse(string $contentType): void { $data = ShortUrlCreation::fromRawData(['longUrl' => self::LONG_URL]); diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 6815acb6..84ceb790 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -30,6 +30,8 @@ use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; use function Laminas\Stratigility\middleware; use function str_starts_with; +use const Shlinkio\Shlink\REDIRECT_URL_REQUEST_ATTRIBUTE; + class ExtraPathRedirectMiddlewareTest extends TestCase { private MockObject & ShortUrlResolverInterface $resolver; @@ -70,7 +72,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase public static function provideNonRedirectingRequests(): iterable { $baseReq = ServerRequestFactory::fromGlobals(); - $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => + $buildReq = static fn (NotFoundType|null $type): ServerRequestInterface => $baseReq->withAttribute(NotFoundType::class, $type); yield 'disabled option' => [false, false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; @@ -127,7 +129,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( bool $multiSegmentEnabled, int $expectedResolveCalls, - ?string $expectedExtraPath, + string|null $expectedExtraPath, ): void { $options = new UrlShortenerOptions(appendExtraPath: true, multiSegmentSlugsEnabled: $multiSegmentEnabled); @@ -159,7 +161,10 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $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->requestTracker->expects($this->once())->method('trackIfApplicable')->with( + $shortUrl, + $request->withAttribute(REDIRECT_URL_REQUEST_ATTRIBUTE, 'the_built_long_url'), + ); $this->middleware($options)->process($request, $this->handler); } @@ -170,7 +175,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase yield [true, 3, null]; } - private function middleware(?UrlShortenerOptions $options = null): ExtraPathRedirectMiddleware + private function middleware(UrlShortenerOptions|null $options = null): ExtraPathRedirectMiddleware { return new ExtraPathRedirectMiddleware( $this->resolver, diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index e963923b..ed9c6459 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -147,7 +147,7 @@ class ShortUrlCreationTest extends TestCase } #[Test, DataProvider('provideTitles')] - public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void + public function titleIsCroppedIfTooLong(string|null $title, string|null $expectedTitle): void { $creation = ShortUrlCreation::fromRawData([ 'title' => $title, @@ -170,7 +170,7 @@ class ShortUrlCreationTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void + public function emptyDomainIsDiscarded(string|null $domain, string|null $expectedDomain): void { $creation = ShortUrlCreation::fromRawData([ 'domain' => $domain, diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 2ef213d2..473c2320 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -28,11 +28,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase #[Test, DataProvider('provideFilteringArgs')] public function getItemsFallsBackToFindList( - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?string $startDate = null, - ?string $endDate = null, - ?string $orderBy = null, + string|null $startDate = null, + string|null $endDate = null, + string|null $orderBy = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, @@ -54,10 +54,10 @@ class ShortUrlRepositoryAdapterTest extends TestCase #[Test, DataProvider('provideFilteringArgs')] public function countFallsBackToCountList( - ?string $searchTerm = null, + string|null $searchTerm = null, array $tags = [], - ?string $startDate = null, - ?string $endDate = null, + string|null $startDate = null, + string|null $endDate = null, ): void { $params = ShortUrlsParams::fromRawData([ 'searchTerm' => $searchTerm, diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 722ac347..934d8511 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -33,7 +33,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideDomainsThatEmpty')] - public function returnsEmptyInSomeCases(?string $domain): void + public function returnsEmptyInSomeCases(string|null $domain): void { $this->em->expects($this->never())->method('getRepository')->with(Domain::class); self::assertNull($this->resolver->resolveDomain($domain)); @@ -46,7 +46,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideFoundDomains')] - public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void + public function findsOrCreatesDomainWhenValueIsProvided(Domain|null $foundDomain, string $authority): void { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findOneBy')->with(['authority' => $authority])->willReturn($foundDomain); @@ -79,7 +79,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with( $this->isType('array'), - )->willReturnCallback(function (array $criteria): ?Tag { + )->willReturnCallback(function (array $criteria): Tag|null { ['name' => $name] = $criteria; return $name === 'foo' ? new Tag($name) : null; }); diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index f74480ba..95e95785 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -21,7 +21,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase } #[Test, DataProvider('provideDomains')] - public function resolvesExpectedDomain(?string $domain): void + public function resolvesExpectedDomain(string|null $domain): void { $result = $this->resolver->resolveDomain($domain); diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index d8663761..c22bc206 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -30,7 +30,7 @@ class ShortUrlListServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void + public function listedUrlsAreReturnedFromEntityManager(ApiKey|null $apiKey): void { $list = [ ShortUrl::createFake(), @@ -42,7 +42,7 @@ class ShortUrlListServiceTest extends TestCase $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); + $paginator = $this->service->listShortUrls(ShortUrlsParams::empty(), $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 e8443a13..d565a352 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; @@ -31,18 +30,16 @@ use function range; class ShortUrlResolverTest extends TestCase { private ShortUrlResolver $urlResolver; - private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlRepository $repo; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(ShortUrlRepository::class); - $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); + $this->urlResolver = new ShortUrlResolver($this->repo, new UrlShortenerOptions()); } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void + public function shortCodeIsProperlyParsed(ApiKey|null $apiKey): void { $shortUrl = ShortUrl::withLongUrl('https://expected_url'); $shortCode = $shortUrl->getShortCode(); @@ -51,7 +48,6 @@ class ShortUrlResolverTest extends TestCase $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); @@ -59,13 +55,12 @@ class ShortUrlResolverTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function exceptionIsThrownIfShortCodeIsNotFound(?ApiKey $apiKey): void + public function exceptionIsThrownIfShortCodeIsNotFound(ApiKey|null $apiKey): void { $shortCode = 'abc123'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $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); @@ -82,7 +77,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn($shortUrl); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $result = $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); @@ -98,7 +92,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn(null); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); @@ -120,7 +113,6 @@ class ShortUrlResolverTest extends TestCase ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), ShortUrlMode::STRICT, )->willReturn($shortUrl); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); $this->expectException(ShortUrlNotFoundException::class); @@ -136,7 +128,7 @@ class ShortUrlResolverTest extends TestCase ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://longUrl']), ); $shortUrl->setVisits(new ArrayCollection(array_map( - fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()), range(0, 4), ))); @@ -155,7 +147,7 @@ class ShortUrlResolverTest extends TestCase 'longUrl' => 'https://longUrl', ])); $shortUrl->setVisits(new ArrayCollection(array_map( - fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), + fn () => Visit::forValidShortUrl($shortUrl, Visitor::empty()), range(0, 4), ))); diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 669015a0..c3554363 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -48,7 +48,7 @@ class ShortUrlServiceTest extends TestCase public function updateShortUrlUpdatesProvidedData( InvocationOrder $expectedValidateCalls, ShortUrlEdition $shortUrlEdit, - ?ApiKey $apiKey, + ApiKey|null $apiKey, ): void { $originalLongUrl = 'https://originalLongUrl'; $shortUrl = ShortUrl::withLongUrl($originalLongUrl); diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index b332afd2..a6cacc46 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\UrlShortener; @@ -28,6 +28,7 @@ class UrlShortenerTest extends TestCase private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; private MockObject & EventDispatcherInterface $dispatcher; + private MockObject & ShortUrlRepositoryInterface $repo; protected function setUp(): void { @@ -42,6 +43,7 @@ class UrlShortenerTest extends TestCase ); $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); $this->urlShortener = new UrlShortener( $this->titleResolutionHelper, @@ -49,6 +51,7 @@ class UrlShortenerTest extends TestCase new SimpleShortUrlRelationResolver(), $this->shortCodeHelper, $this->dispatcher, + $this->repo, ); } @@ -102,9 +105,7 @@ class UrlShortenerTest extends TestCase #[Test, DataProvider('provideExistingShortUrls')] public function existingShortUrlIsReturnedWhenRequested(ShortUrlCreation $meta, ShortUrl $expected): void { - $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->repo->expects($this->once())->method('findOneMatching')->willReturn($expected); $this->titleResolutionHelper->expects($this->never())->method('processTitle'); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index f22a35f2..4080986f 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; @@ -35,9 +35,8 @@ class TagServiceTest extends TestCase { $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); + $this->service = new TagService($this->em, $this->repo); } #[Test] @@ -55,7 +54,7 @@ class TagServiceTest extends TestCase #[Test, DataProvider('provideApiKeysAndSearchTerm')] public function tagsInfoDelegatesOnRepository( - ?ApiKey $apiKey, + ApiKey|null $apiKey, TagsParams $params, TagsListFiltering $expectedFiltering, int $countCalls, @@ -101,7 +100,7 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void + public function deleteTagsDelegatesOnRepository(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('deleteByName')->with(['foo', 'bar'])->willReturn(4); $this->service->deleteTags(['foo', 'bar'], $apiKey); @@ -122,12 +121,12 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function renameInvalidTagThrowsException(?ApiKey $apiKey): void + public function renameInvalidTagThrowsException(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); $this->expectException(TagNotFoundException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + $this->service->renameTag(Renaming::fromNames('foo', 'bar'), $apiKey); } #[Test, DataProvider('provideValidRenames')] @@ -139,7 +138,7 @@ class TagServiceTest extends TestCase $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)); + $tag = $this->service->renameTag(Renaming::fromNames($oldName, $newName)); self::assertSame($expected, $tag); self::assertEquals($newName, (string) $tag); @@ -152,7 +151,7 @@ class TagServiceTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void + public function renameTagToAnExistingNameThrowsException(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); $this->repo->expects($this->once())->method('count')->willReturn(1); @@ -160,19 +159,19 @@ class TagServiceTest extends TestCase $this->expectException(TagConflictException::class); - $this->service->renameTag(TagRenaming::fromNames('foo', 'bar'), $apiKey); + $this->service->renameTag(Renaming::fromNames('foo', 'bar'), $apiKey); } #[Test] public function renamingTagThrowsExceptionWhenProvidedApiKeyIsNotAdmin(): void { - $this->em->expects($this->never())->method('getRepository')->with(Tag::class); + $this->repo->expects($this->never())->method('findOneBy'); $this->expectExceptionMessage(ForbiddenTagOperationException::class); $this->expectExceptionMessage('You are not allowed to rename tags'); $this->service->renameTag( - TagRenaming::fromNames('foo', 'bar'), + Renaming::fromNames('foo', 'bar'), ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())), ); } diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index 89d3fa5a..b01333d5 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -18,7 +18,7 @@ class RedirectResponseHelperTest extends TestCase int $configuredStatus, int $configuredLifetime, int $expectedStatus, - ?string $expectedCacheControl, + string|null $expectedCacheControl, ): void { $options = new RedirectOptions($configuredStatus, $configuredLifetime); @@ -46,7 +46,7 @@ class RedirectResponseHelperTest extends TestCase yield 'status 308 with negative expiration' => [308, -20, 308, 'private,max-age=30']; } - private function helper(?RedirectOptions $options = null): RedirectResponseHelper + private function helper(RedirectOptions|null $options = null): RedirectResponseHelper { return new RedirectResponseHelper($options ?? new RedirectOptions()); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 923b2e6b..438ca55f 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -22,7 +22,10 @@ class VisitTest extends TestCase #[Test, DataProvider('provideUserAgents')] public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void { - $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl( + ShortUrl::createFake(), + Visitor::fromParams($userAgent, 'some site', '1.2.3.4'), + ); self::assertEquals([ 'referer' => 'some site', @@ -31,6 +34,7 @@ class VisitTest extends TestCase 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, 'visitedUrl' => $visit->visitedUrl, + 'redirectUrl' => $visit->redirectUrl, ], $visit->jsonSerialize()); } @@ -55,7 +59,7 @@ class VisitTest extends TestCase public static function provideOrphanVisits(): iterable { yield 'base path visit' => [ - $visit = Visit::forBasePath(Visitor::emptyInstance()), + $visit = Visit::forBasePath(Visitor::empty()), [ 'referer' => '', 'date' => $visit->date->toAtomString(), @@ -64,6 +68,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => VisitType::BASE_URL->value, + 'redirectUrl' => null, ], ]; yield 'invalid short url visit' => [ @@ -80,6 +85,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', 'type' => VisitType::INVALID_SHORT_URL->value, + 'redirectUrl' => null, ], ]; yield 'regular 404 visit' => [ @@ -98,16 +104,20 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://s.test/foo/bar', 'type' => VisitType::REGULAR_404->value, + 'redirectUrl' => null, ], ]; } #[Test, DataProvider('provideAddresses')] - public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void - { + public function addressIsAnonymizedWhenRequested( + bool $anonymize, + string|null $address, + string|null $expectedAddress, + ): void { $visit = Visit::forValidShortUrl( ShortUrl::createFake(), - new Visitor('Chrome', 'some site', $address, ''), + Visitor::fromParams('Chrome', 'some site', $address), $anonymize, ); diff --git a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php index f1d86f63..cd6f12da 100644 --- a/module/Core/test/Visit/Geolocation/VisitLocatorTest.php +++ b/module/Core/test/Visit/Geolocation/VisitLocatorTest.php @@ -48,7 +48,7 @@ class VisitLocatorTest extends TestCase $unlocatedVisits = array_map( fn (int $i) => Visit::forValidShortUrl( ShortUrl::withLongUrl(sprintf('https://short_code_%s', $i)), - Visitor::emptyInstance(), + Visitor::empty(), ), range(1, 200), ); @@ -87,7 +87,7 @@ class VisitLocatorTest extends TestCase bool $isNonLocatableAddress, ): void { $unlocatedVisits = [ - Visit::forValidShortUrl(ShortUrl::withLongUrl('https://foo'), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://foo'), Visitor::empty()), ]; $this->repo->expects($this->once())->method($expectedRepoMethodName)->willReturn($unlocatedVisits); diff --git a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php index 57926afe..a9d8f3e5 100644 --- a/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php +++ b/module/Core/test/Visit/Geolocation/VisitToLocationHelperTest.php @@ -40,9 +40,9 @@ class VisitToLocationHelperTest extends TestCase public static function provideNonLocatableVisits(): iterable { - yield [Visit::forBasePath(Visitor::emptyInstance()), IpCannotBeLocatedException::forEmptyAddress()]; + yield [Visit::forBasePath(Visitor::empty()), IpCannotBeLocatedException::forEmptyAddress()]; yield [ - Visit::forBasePath(new Visitor('foo', 'bar', IpAddress::LOCALHOST, '')), + Visit::forBasePath(Visitor::fromParams('foo', 'bar', IpAddress::LOCALHOST)), IpCannotBeLocatedException::forLocalhost(), ]; } @@ -55,6 +55,6 @@ class VisitToLocationHelperTest extends TestCase $this->expectExceptionObject(IpCannotBeLocatedException::forError($e)); $this->ipLocationResolver->expects($this->once())->method('resolveIpLocation')->willThrowException($e); - $this->helper->resolveVisitLocation(Visit::forBasePath(new Visitor('foo', 'bar', '1.2.3.4', ''))); + $this->helper->resolveVisitLocation(Visit::forBasePath(Visitor::fromParams('foo', 'bar', '1.2.3.4'))); } } diff --git a/module/Core/test/Visit/Model/VisitorTest.php b/module/Core/test/Visit/Model/VisitorTest.php index 04e57179..25be7440 100644 --- a/module/Core/test/Visit/Model/VisitorTest.php +++ b/module/Core/test/Visit/Model/VisitorTest.php @@ -20,7 +20,7 @@ class VisitorTest extends TestCase #[Test, DataProvider('provideParams')] public function providedFieldsValuesAreCropped(array $params, array $expected): void { - $visitor = new Visitor(...$params); + $visitor = Visitor::fromParams(...$params); ['userAgent' => $userAgent, 'referer' => $referer, 'remoteAddress' => $remoteAddress] = $expected; self::assertEquals($userAgent, $visitor->userAgent); @@ -75,7 +75,7 @@ class VisitorTest extends TestCase #[Test] public function newNormalizedInstanceIsCreatedFromTrackingOptions(): void { - $visitor = new Visitor( + $visitor = Visitor::fromParams( self::generateRandomString(2000), self::generateRandomString(2000), self::generateRandomString(2000), diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php index dd998f71..2dbaa25a 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -53,7 +53,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase #[Test, DataProvider('provideLimitAndOffset')] public function getSliceDelegatesToRepository(int $limit, int $offset): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $this->repo->expects($this->once())->method('findNonOrphanVisits')->with(new VisitsListFiltering( $this->params->dateRange, diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 3e50faf0..b62fa0c6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -27,7 +27,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); - $this->params = OrphanVisitsParams::fromRawData([]); + $this->params = new OrphanVisitsParams(); $this->apiKey = ApiKey::create(); $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); @@ -53,7 +53,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase #[Test, DataProvider('provideLimitAndOffset')] public function getSliceDelegatesToRepository(int $limit, int $offset): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $this->repo->expects($this->once())->method('findOrphanVisits')->with(new OrphanVisitsListFiltering( dateRange: $this->params->dateRange, diff --git a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index c96e5be5..d1f7c89b 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -58,7 +58,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase } } - private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter + private function createAdapter(ApiKey|null $apiKey): ShortUrlVisitsPaginatorAdapter { return new ShortUrlVisitsPaginatorAdapter( $this->repo, diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 59ce2082..c0cd4d0b 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -57,7 +57,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase } } - private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter + private function createAdapter(ApiKey|null $apiKey): TagVisitsPaginatorAdapter { 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 ae0a74c4..f9357c6a 100644 --- a/module/Core/test/Visit/RequestTrackerTest.php +++ b/module/Core/test/Visit/RequestTrackerTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Core\Config\Options\TrackingOptions; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -20,6 +19,8 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\RequestTracker; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; +use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE; + class RequestTrackerTest extends TestCase { private const LONG_URL = 'https://domain.com/foo/bar?some=thing'; @@ -67,15 +68,15 @@ class RequestTrackerTest extends TestCase ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]), ]; yield 'exact remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '80.90.100.110', )]; yield 'matching wildcard remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '1.2.3.4', )]; yield 'matching CIDR block remote address' => [ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, '192.168.10.100', )]; } @@ -102,7 +103,7 @@ class RequestTrackerTest extends TestCase ); $this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute( - IpAddressMiddlewareFactory::REQUEST_ATTR, + IP_ADDRESS_REQUEST_ATTRIBUTE, 'invalid', )); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index c1aa0747..d6762c00 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -56,7 +56,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProvider('provideCounts')] - public function returnsExpectedVisitsStats(int $expectedCount, ?ApiKey $apiKey): void + public function returnsExpectedVisitsStats(int $expectedCount, ApiKey|null $apiKey): void { $callCount = 0; $visitsCountRepo = $this->createMock(ShortUrlVisitsCountRepository::class); @@ -94,7 +94,7 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void + public function infoReturnsVisitsForCertainShortCode(ApiKey|null $apiKey): void { $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); @@ -104,7 +104,7 @@ class VisitsStatsHelperTest extends TestCase $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -157,14 +157,14 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForTagAreReturnedAsExpected(ApiKey|null $apiKey): void { $tag = 'foo'; $repo = $this->createMock(TagRepository::class); $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -198,14 +198,14 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForNonDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey): void { $domain = 'foo.com'; $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); @@ -229,22 +229,22 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] - public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + public function visitsForDefaultDomainAreReturnedAsExpected(ApiKey|null $apiKey): void { $repo = $this->createMock(DomainRepository::class); $repo->expects($this->never())->method('domainExists'); $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 1), ); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( - 'DEFAULT', + Domain::DEFAULT_AUTHORITY, $this->isInstanceOf(VisitsListFiltering::class), )->willReturn($list); $repo2->method('countVisitsByDomain')->with( - 'DEFAULT', + Domain::DEFAULT_AUTHORITY, $this->isInstanceOf(VisitsCountFiltering::class), )->willReturn(1); @@ -253,7 +253,7 @@ class VisitsStatsHelperTest extends TestCase [Visit::class, $repo2], ]); - $paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey); + $paginator = $this->helper->visitsForDomain(Domain::DEFAULT_AUTHORITY, new VisitsParams(), $apiKey); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } @@ -261,7 +261,7 @@ class VisitsStatsHelperTest extends TestCase #[Test] public function orphanVisitsAreReturnedAsExpected(): void { - $list = array_map(static fn () => Visit::forBasePath(Visitor::emptyInstance()), range(0, 3)); + $list = array_map(static fn () => Visit::forBasePath(Visitor::empty()), range(0, 3)); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countOrphanVisits')->with( $this->isInstanceOf(OrphanVisitsCountFiltering::class), @@ -280,7 +280,7 @@ class VisitsStatsHelperTest extends TestCase public function nonOrphanVisitsAreReturnedAsExpected(): void { $list = array_map( - static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), + static fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::empty()), range(0, 3), ); $repo = $this->createMock(VisitRepository::class); diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index 414f5254..bfcf2828 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -33,43 +33,44 @@ class VisitsTrackerTest extends TestCase #[Test, DataProvider('provideTrackingMethodNames')] public function trackPersistsVisitAndDispatchesEvent(string $method, array $args): 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->em->expects($this->once())->method('persist')->with($this->isInstanceOf(Visit::class)); $this->eventDispatcher->expects($this->once())->method('dispatch')->with( $this->isInstanceOf(UrlVisited::class), ); - $this->visitsTracker()->{$method}(...$args); + $result = $this->visitsTracker()->{$method}(...$args); + + self::assertInstanceOf(Visit::class, $result); } #[Test, DataProvider('provideTrackingMethodNames')] public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void { $this->em->expects($this->never())->method('persist'); - $this->em->expects($this->never())->method('flush'); $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); + $result = $this->visitsTracker(new TrackingOptions(disableTracking: true))->{$method}(...$args); + + self::assertNull($result); } public static function provideTrackingMethodNames(): iterable { - yield 'track' => ['track', [ShortUrl::createFake(), Visitor::emptyInstance()]]; - yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; - yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; - yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; + yield 'track' => ['track', [ShortUrl::createFake(), Visitor::empty()]]; + yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::empty()]]; + yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::empty()]]; + yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::empty()]]; } #[Test, DataProvider('provideOrphanTrackingMethodNames')] public function orphanVisitsAreNotTrackedWhenDisabled(string $method): void { $this->em->expects($this->never())->method('persist'); - $this->em->expects($this->never())->method('flush'); $this->eventDispatcher->expects($this->never())->method('dispatch'); - $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::emptyInstance()); + $result = $this->visitsTracker(new TrackingOptions(trackOrphanVisits: false))->{$method}(Visitor::empty()); + + self::assertNull($result); } public static function provideOrphanTrackingMethodNames(): iterable @@ -79,7 +80,7 @@ class VisitsTrackerTest extends TestCase yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit']; } - private function visitsTracker(?TrackingOptions $options = null): VisitsTracker + private function visitsTracker(TrackingOptions|null $options = null): VisitsTracker { 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 b69cf36d..df482a46 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -9,6 +9,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\ProblemDetails\ProblemDetailsResponseFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Config; use Shlinkio\Shlink\Core\Domain\DomainService; @@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Service\ApiKeyService; return [ @@ -24,6 +26,7 @@ return [ 'dependencies' => [ 'factories' => [ ApiKeyService::class => ConfigAbstractFactory::class, + ApiKeyRepository::class => [EntityRepositoryFactory::class, Entity\ApiKey::class], Action\HealthAction::class => ConfigAbstractFactory::class, Action\MercureInfoAction::class => ConfigAbstractFactory::class, @@ -62,7 +65,7 @@ return [ ], ConfigAbstractFactory::class => [ - ApiKeyService::class => ['em'], + ApiKeyService::class => ['em', ApiKeyRepository::class], Action\HealthAction::class => ['em', Config\Options\AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], diff --git a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php index e2b27e23..0c55f967 100644 --- a/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php +++ b/module/Rest/src/Action/Domain/Request/DomainRedirectsRequest.php @@ -14,11 +14,11 @@ use function array_key_exists; class DomainRedirectsRequest { private string $authority; - private ?string $baseUrlRedirect = null; + private string|null $baseUrlRedirect = null; private bool $baseUrlRedirectWasProvided = false; - private ?string $regular404Redirect = null; + private string|null $regular404Redirect = null; private bool $regular404RedirectWasProvided = false; - private ?string $invalidShortUrlRedirect = null; + private string|null $invalidShortUrlRedirect = null; private bool $invalidShortUrlRedirectWasProvided = false; private function __construct() @@ -66,7 +66,7 @@ class DomainRedirectsRequest return $this->authority; } - public function toNotFoundRedirects(?NotFoundRedirectConfigInterface $defaults = null): NotFoundRedirects + public function toNotFoundRedirects(NotFoundRedirectConfigInterface|null $defaults = null): NotFoundRedirects { return NotFoundRedirects::withRedirects( $this->baseUrlRedirectWasProvided ? $this->baseUrlRedirect : $defaults?->baseUrlRedirect(), diff --git a/module/Rest/src/Action/Tag/UpdateTagAction.php b/module/Rest/src/Action/Tag/UpdateTagAction.php index 016d008b..e1dc1611 100644 --- a/module/Rest/src/Action/Tag/UpdateTagAction.php +++ b/module/Rest/src/Action/Tag/UpdateTagAction.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\EmptyResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -27,7 +27,7 @@ class UpdateTagAction extends AbstractRestAction $body = $request->getParsedBody(); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $this->tagService->renameTag(TagRenaming::fromArray($body), $apiKey); + $this->tagService->renameTag(Renaming::fromArray($body), $apiKey); return new EmptyResponse(); } } diff --git a/module/Rest/src/Action/Visit/AbstractListVisitsAction.php b/module/Rest/src/Action/Visit/AbstractListVisitsAction.php new file mode 100644 index 00000000..090d3078 --- /dev/null +++ b/module/Rest/src/Action/Visit/AbstractListVisitsAction.php @@ -0,0 +1,44 @@ +getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->getVisitsPaginator($request, $params, $apiKey); + + return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + } + + /** + * @return Pagerfanta + */ + abstract protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta; +} diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php index 4d534202..ee1625e0 100644 --- a/module/Rest/src/Action/Visit/DomainVisitsAction.php +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -4,42 +4,36 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class DomainVisitsAction extends AbstractRestAction +class DomainVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/domains/{domain}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct( - private readonly VisitsStatsHelperInterface $visitsHelper, + VisitsStatsHelperInterface $visitsHelper, private readonly UrlShortenerOptions $urlShortenerOptions, ) { + parent::__construct($visitsHelper); } - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $domain = $this->resolveDomainParam($request); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); } private function resolveDomainParam(Request $request): string { $domainParam = $request->getAttribute('domain', ''); if ($domainParam === $this->urlShortenerOptions->defaultDomain) { - return 'DEFAULT'; + return Domain::DEFAULT_AUTHORITY; } return $domainParam; diff --git a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php index 1fffdb8b..8406b515 100644 --- a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php @@ -4,30 +4,20 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class NonOrphanVisitsAction extends AbstractRestAction +class NonOrphanVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/visits/non-orphan'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->nonOrphanVisits($params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta { + return $this->visitsHelper->nonOrphanVisits($params, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index 7906fdae..341524c3 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -4,30 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class OrphanVisitsAction extends AbstractRestAction +class OrphanVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/visits/orphan'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(ServerRequestInterface $request): ResponseInterface - { - $params = OrphanVisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->orphanVisits($params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + protected function getVisitsPaginator( + ServerRequestInterface $request, + VisitsParams $params, + ApiKey $apiKey, + ): Pagerfanta { + $orphanParams = OrphanVisitsParams::fromVisitsParamsAndRawData($params, $request->getQueryParams()); + return $this->visitsHelper->orphanVisits($orphanParams, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php index fe5099a2..95ac42cc 100644 --- a/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php +++ b/module/Rest/src/Action/Visit/ShortUrlVisitsAction.php @@ -4,32 +4,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlVisitsAction extends AbstractRestAction +class ShortUrlVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/short-urls/{shortCode}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $identifier = ShortUrlIdentifier::fromApiRequest($request); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForShortUrl($identifier, $params, $apiKey); } } diff --git a/module/Rest/src/Action/Visit/TagVisitsAction.php b/module/Rest/src/Action/Visit/TagVisitsAction.php index 1739264f..08553ec5 100644 --- a/module/Rest/src/Action/Visit/TagVisitsAction.php +++ b/module/Rest/src/Action/Visit/TagVisitsAction.php @@ -4,31 +4,18 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\Visit; -use Laminas\Diactoros\Response\JsonResponse; -use Psr\Http\Message\ResponseInterface as Response; +use Pagerfanta\Pagerfanta; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; -use Shlinkio\Shlink\Rest\Action\AbstractRestAction; -use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class TagVisitsAction extends AbstractRestAction +class TagVisitsAction extends AbstractListVisitsAction { protected const ROUTE_PATH = '/tags/{tag}/visits'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper) - { - } - - public function handle(Request $request): Response + protected function getVisitsPaginator(Request $request, VisitsParams $params, ApiKey $apiKey): Pagerfanta { $tag = $request->getAttribute('tag', ''); - $params = VisitsParams::fromRawData($request->getQueryParams()); - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - $visits = $this->visitsHelper->visitsForTag($tag, $params, $apiKey); - - return new JsonResponse(['visits' => PagerfantaUtils::serializePaginator($visits)]); + return $this->visitsHelper->visitsForTag($tag, $params, $apiKey); } } diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index e28a9ec3..ae1b189c 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -7,20 +7,24 @@ namespace Shlinkio\Shlink\Rest\ApiKey\Model; use Cake\Chronos\Chronos; use Ramsey\Uuid\Uuid; -final class ApiKeyMeta +use function sprintf; +use function substr; + +final readonly class ApiKeyMeta { /** * @param iterable $roleDefinitions */ private function __construct( - public readonly string $key, - public readonly ?string $name, - public readonly ?Chronos $expirationDate, - public readonly iterable $roleDefinitions, + public string $key, + public string $name, + public bool $isNameAutoGenerated, + public Chronos|null $expirationDate, + public iterable $roleDefinitions, ) { } - public static function empty(): self + public static function create(): self { return self::fromParams(); } @@ -29,14 +33,27 @@ final class ApiKeyMeta * @param iterable $roleDefinitions */ public static function fromParams( - ?string $key = null, - ?string $name = null, - ?Chronos $expirationDate = null, + string|null $key = null, + string|null $name = null, + Chronos|null $expirationDate = null, iterable $roleDefinitions = [], ): self { + $resolvedKey = $key ?? Uuid::uuid4()->toString(); + $isNameAutoGenerated = empty($name); + + // If a name was not provided, fall back to the key + if ($isNameAutoGenerated) { + // If the key was auto-generated, fall back to a redacted version of the UUID, otherwise simply use the + // plain key as fallback name + $name = $key === null + ? sprintf('%s-****-****-****-************', substr($resolvedKey, offset: 0, length: 8)) + : $key; + } + return new self( - key: $key ?? Uuid::uuid4()->toString(), + key: $resolvedKey, name: $name, + isNameAutoGenerated: $isNameAutoGenerated, expirationDate: $expirationDate, roleDefinitions: $roleDefinitions, ); diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php index 2b7aa0a2..6b282a07 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepository.php @@ -15,31 +15,48 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ApiKeyRepository extends EntitySpecificationRepository implements ApiKeyRepositoryInterface { /** - * Will create provided API key with admin permissions, only if there's no other API keys yet + * @inheritDoc */ - public function createInitialApiKey(string $apiKey): ?ApiKey + public function createInitialApiKey(string $apiKey): ApiKey|null { $em = $this->getEntityManager(); - return $em->wrapInTransaction(function () use ($apiKey, $em): ?ApiKey { - // Ideally this would be a SELECT COUNT(...), but MsSQL and Postgres do not allow locking on aggregates - // Because of that we check if at least one result exists - $firstResult = $em->createQueryBuilder()->select('a.id') - ->from(ApiKey::class, 'a') - ->setMaxResults(1) - ->getQuery() - ->setLockMode(LockMode::PESSIMISTIC_WRITE) - ->getOneOrNullResult(); + return $em->wrapInTransaction(function () use ($apiKey, $em): ApiKey|null { + $firstResult = $em->createQueryBuilder() + ->select('a.id') + ->from(ApiKey::class, 'a') + ->setMaxResults(1) + ->getQuery() + ->setLockMode(LockMode::PESSIMISTIC_WRITE) + ->getOneOrNullResult(); // Do not create an initial API key if other keys already exist if ($firstResult !== null) { return null; } - $new = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)); - $em->persist($new); - $em->flush(); + $initialApiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $apiKey)); + $em->persist($initialApiKey); - return $new; + return $initialApiKey; }); } + + /** + * @inheritDoc + */ + public function nameExists(string $name): bool + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('a.id') + ->from(ApiKey::class, 'a') + ->where($qb->expr()->eq('a.name', ':name')) + ->setParameter('name', $name) + ->setMaxResults(1); + + // Lock for update, to avoid a race condition that inserts a duplicate name after we have checked if one existed + $query = $qb->getQuery(); + $query->setLockMode(LockMode::PESSIMISTIC_WRITE); + + return $query->getOneOrNullResult() !== null; + } } diff --git a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php index 57c2a7f6..32ada38a 100644 --- a/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php +++ b/module/Rest/src/ApiKey/Repository/ApiKeyRepositoryInterface.php @@ -4,17 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\ApiKey\Repository; -use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; +use Shlinkio\Shlink\Core\Repository\EntityRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; /** - * @extends ObjectRepository + * @extends EntityRepositoryInterface */ -interface ApiKeyRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface +interface ApiKeyRepositoryInterface extends EntityRepositoryInterface, EntitySpecificationRepositoryInterface { /** - * Will create provided API key only if there's no API keys yet + * Will create provided API key with admin permissions, only if no other API keys exist yet */ - public function createInitialApiKey(string $apiKey): ?ApiKey; + public function createInitialApiKey(string $apiKey): ApiKey|null; + + /** + * Checks whether an API key with provided name exists or not + */ + public function nameExists(string $name): bool; } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 4f3685db..7cca292d 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -38,7 +38,7 @@ enum Role: string }; } - public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification + public static function toSpec(ApiKeyRole $role, string|null $context = null): Specification { return match ($role->role) { self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey, $context), diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 122829ed..d7c2e7ba 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,8 +11,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - public function __construct(private readonly ?ApiKey $apiKey, private readonly string $fieldToJoin = 'shortUrls') - { + public function __construct( + private readonly ApiKey|null $apiKey, + private readonly string $fieldToJoin = 'shortUrls', + ) { parent::__construct(); } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 067c6952..1768c7c8 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -33,7 +33,7 @@ class ConfigProvider return $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; } - private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array + private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): array|null { $healthRoutes = array_filter($routes, fn (array $route) => $route['path'] === '/health'); $healthRoute = reset($healthRoutes); diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 46548dcf..63bb6fc9 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Entity; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Exception; use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Common\Entity\AbstractEntity; @@ -15,35 +14,31 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\ApiKey\Role; +use function hash; + class ApiKey extends AbstractEntity { /** * @param Collection $roles - * @throws Exception */ private function __construct( - private string $key, - public readonly ?string $name = null, - public readonly ?Chronos $expirationDate = null, + public readonly string $key, + // TODO Use a property hook to allow public read but private write + public string $name, + public readonly Chronos|null $expirationDate = null, private bool $enabled = true, private Collection $roles = new ArrayCollection(), ) { } - /** - * @throws Exception - */ public static function create(): ApiKey { - return self::fromMeta(ApiKeyMeta::empty()); + return self::fromMeta(ApiKeyMeta::create()); } - /** - * @throws Exception - */ public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($meta->key, $meta->name, $meta->expirationDate); + $apiKey = new self(self::hashKey($meta->key), $meta->name, $meta->expirationDate); foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } @@ -51,6 +46,14 @@ class ApiKey extends AbstractEntity return $apiKey; } + /** + * Generates a hash for provided key, in the way Shlink expects API keys to be hashed + */ + public static function hashKey(string $key): string + { + return hash('sha256', $key); + } + public function isExpired(): bool { return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now()); @@ -75,17 +78,7 @@ class ApiKey extends AbstractEntity return $this->isEnabled() && ! $this->isExpired(); } - public function __toString(): string - { - return $this->key; - } - - public function toString(): string - { - return $this->key; - } - - public function spec(?string $context = null): Specification + public function spec(string|null $context = null): Specification { $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $context))->getValues(); return Spec::andX(...$specs); @@ -100,7 +93,7 @@ class ApiKey extends AbstractEntity /** * @return ($apiKey is null ? true : boolean) */ - public static function isAdmin(?ApiKey $apiKey): bool + public static function isAdmin(ApiKey|null $apiKey): bool { return $apiKey === null || $apiKey->roles->isEmpty(); } @@ -108,7 +101,7 @@ class ApiKey extends AbstractEntity /** * Tells if provided API key has any of the roles restricting at the short URL level */ - public static function isShortUrlRestricted(?ApiKey $apiKey): bool + public static function isShortUrlRestricted(ApiKey|null $apiKey): bool { if ($apiKey === null) { return false; diff --git a/module/Rest/src/Exception/ApiKeyConflictException.php b/module/Rest/src/Exception/ApiKeyConflictException.php new file mode 100644 index 00000000..a1ffce03 --- /dev/null +++ b/module/Rest/src/Exception/ApiKeyConflictException.php @@ -0,0 +1,15 @@ +em->wrapInTransaction(function () use ($apiKeyMeta) { + $apiKey = ApiKey::fromMeta($this->ensureUniqueName($apiKeyMeta)); + $this->em->persist($apiKey); - $this->em->persist($apiKey); - $this->em->flush(); - - return $apiKey; + return $apiKey; + }); } - public function createInitial(string $key): ?ApiKey + /** + * Given an ApiKeyMeta object, it returns another instance ensuring the name is unique. + * - If the name was auto-generated, it continues re-trying until a unique name is resolved. + * - If the name was explicitly provided, it throws in case of name conflict. + */ + private function ensureUniqueName(ApiKeyMeta $apiKeyMeta): ApiKeyMeta { - /** @var ApiKeyRepositoryInterface $repo */ - $repo = $this->em->getRepository(ApiKey::class); - return $repo->createInitialApiKey($key); + if (! $this->repo->nameExists($apiKeyMeta->name)) { + return $apiKeyMeta; + } + + if (! $apiKeyMeta->isNameAutoGenerated) { + throw new InvalidArgumentException( + sprintf('Another API key with name "%s" already exists', $apiKeyMeta->name), + ); + } + + return $this->ensureUniqueName(ApiKeyMeta::fromParams( + expirationDate: $apiKeyMeta->expirationDate, + roleDefinitions: $apiKeyMeta->roleDefinitions, + )); + } + + public function createInitial(string $key): ApiKey|null + { + return $this->repo->createInitialApiKey($key); } public function check(string $key): ApiKeyCheckResult { - $apiKey = $this->getByKey($key); + $apiKey = $this->findByKey($key); return new ApiKeyCheckResult($apiKey); } /** - * @throws InvalidArgumentException + * @inheritDoc */ - public function disable(string $key): ApiKey + public function disableByName(string $apiKeyName): ApiKey { - $apiKey = $this->getByKey($key); + $apiKey = $this->repo->findOneBy(['name' => $apiKeyName]); if ($apiKey === null) { - throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key)); + throw ApiKeyNotFoundException::forName($apiKeyName); } + return $this->disableApiKey($apiKey); + } + + /** + * @inheritDoc + */ + public function disableByKey(string $key): ApiKey + { + $apiKey = $this->findByKey($key); + if ($apiKey === null) { + throw ApiKeyNotFoundException::forKey($key); + } + + return $this->disableApiKey($apiKey); + } + + private function disableApiKey(ApiKey $apiKey): ApiKey + { $apiKey->disable(); $this->em->flush(); + return $apiKey; } @@ -62,17 +108,36 @@ class ApiKeyService implements ApiKeyServiceInterface public function listKeys(bool $enabledOnly = false): array { $conditions = $enabledOnly ? ['enabled' => true] : []; - /** @var ApiKey[] $apiKeys */ - $apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions); - return $apiKeys; + return $this->repo->findBy($conditions); } - private function getByKey(string $key): ?ApiKey + /** + * @inheritDoc + */ + public function renameApiKey(Renaming $apiKeyRenaming): ApiKey { - /** @var ApiKey|null $apiKey */ - $apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([ - 'key' => $key, - ]); + $apiKey = $this->repo->findOneBy(['name' => $apiKeyRenaming->oldName]); + if ($apiKey === null) { + throw ApiKeyNotFoundException::forName($apiKeyRenaming->oldName); + } + + if (! $apiKeyRenaming->nameChanged()) { + return $apiKey; + } + + $this->em->wrapInTransaction(function () use ($apiKeyRenaming, $apiKey): void { + if ($this->repo->nameExists($apiKeyRenaming->newName)) { + throw ApiKeyConflictException::forName($apiKeyRenaming->newName); + } + + $apiKey->name = $apiKeyRenaming->newName; + }); + return $apiKey; } + + private function findByKey(string $key): ApiKey|null + { + return $this->repo->findOneBy(['key' => ApiKey::hashKey($key)]); + } } diff --git a/module/Rest/src/Service/ApiKeyServiceInterface.php b/module/Rest/src/Service/ApiKeyServiceInterface.php index b82d7760..745355d7 100644 --- a/module/Rest/src/Service/ApiKeyServiceInterface.php +++ b/module/Rest/src/Service/ApiKeyServiceInterface.php @@ -5,24 +5,42 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Service; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Exception\ApiKeyConflictException; +use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException; interface ApiKeyServiceInterface { + /** + * @throws InvalidArgumentException + */ public function create(ApiKeyMeta $apiKeyMeta): ApiKey; - public function createInitial(string $key): ?ApiKey; + public function createInitial(string $key): ApiKey|null; public function check(string $key): ApiKeyCheckResult; /** - * @throws InvalidArgumentException + * @throws ApiKeyNotFoundException */ - public function disable(string $key): ApiKey; + public function disableByName(string $apiKeyName): ApiKey; + + /** + * @deprecated Use `self::disableByName($name)` instead + * @throws ApiKeyNotFoundException + */ + public function disableByKey(string $key): ApiKey; /** * @return ApiKey[] */ public function listKeys(bool $enabledOnly = false): array; + + /** + * @throws ApiKeyNotFoundException + * @throws ApiKeyConflictException + */ + public function renameApiKey(Renaming $apiKeyRenaming): ApiKey; } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 42742bbb..212b545c 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -39,7 +39,7 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideConflictingSlugs')] - public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, ?string $domain): void + public function failsToCreateShortUrlWithDuplicatedSlug(string $slug, string|null $domain): void { $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $detail = sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix); @@ -171,8 +171,10 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideConflictingSlugs')] - public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse(string $slug, ?string $domain): void - { + public function returnsErrorWhenRequestingReturnExistingButCustomSlugIsInUse( + string $slug, + string|null $domain, + ): void { $longUrl = 'https://www.alejandrocelaya.com'; [$firstStatusCode] = $this->createShortUrl(['longUrl' => $longUrl]); @@ -269,7 +271,7 @@ class CreateShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function apiKeyDomainIsEnforced(?string $providedDomain): void + public function apiKeyDomainIsEnforced(string|null $providedDomain): void { [$statusCode, ['domain' => $returnedDomain]] = $this->createShortUrl( ['domain' => $providedDomain], @@ -315,7 +317,7 @@ class CreateShortUrlTest extends ApiTestCase #[Test] #[TestWith([null])] #[TestWith(['my-custom-slug'])] - public function prefixCanBeSet(?string $customSlug): void + public function prefixCanBeSet(string|null $customSlug): void { [$statusCode, $payload] = $this->createShortUrl([ 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 06848c48..90df09c4 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -18,7 +18,7 @@ class DeleteShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php index 3a06257b..628b7211 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -7,6 +7,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function sprintf; @@ -34,11 +35,11 @@ class DomainVisitsTest extends ApiTestCase public static function provideDomains(): iterable { yield 'example.com with admin API key' => ['valid_api_key', 'example.com', false, 0]; - yield 'DEFAULT with admin API key' => ['valid_api_key', 'DEFAULT', false, 7]; - yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', 'DEFAULT', true, 6]; - yield 'DEFAULT with domain API key' => ['domain_api_key', 'DEFAULT', false, 0]; - yield 'DEFAULT with author API key' => ['author_api_key', 'DEFAULT', false, 5]; - yield 'DEFAULT with author API key and no bots' => ['author_api_key', 'DEFAULT', true, 4]; + yield 'DEFAULT with admin API key' => ['valid_api_key', Domain::DEFAULT_AUTHORITY, false, 7]; + yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', Domain::DEFAULT_AUTHORITY, true, 6]; + yield 'DEFAULT with domain API key' => ['domain_api_key', Domain::DEFAULT_AUTHORITY, false, 0]; + yield 'DEFAULT with author API key' => ['author_api_key', Domain::DEFAULT_AUTHORITY, false, 5]; + yield 'DEFAULT with author API key and no bots' => ['author_api_key', Domain::DEFAULT_AUTHORITY, true, 4]; } #[Test, DataProvider('provideApiKeysAndTags')] diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 24f91e58..2146a95c 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -90,7 +90,7 @@ class EditShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { @@ -125,7 +125,7 @@ class EditShortUrlTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function metadataIsEditedOnProperShortUrlBasedOnDomain(?string $domain, string $expectedUrl): void + public function metadataIsEditedOnProperShortUrlBasedOnDomain(string|null $domain, string $expectedUrl): void { $shortCode = 'ghi789'; $url = new Uri(sprintf('/short-urls/%s', $shortCode)); diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e3fc49a6..1eba6db8 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -34,6 +35,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => 'My cool title', 'crawlable' => true, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', @@ -55,6 +57,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [ 'shortCode' => 'custom-with-domain', @@ -76,6 +79,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', @@ -99,6 +103,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => true, ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', @@ -120,6 +125,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => true, 'forwardQuery' => false, + 'hasRedirectRules' => false, ]; private const SHORT_URL_CUSTOM_DOMAIN = [ 'shortCode' => 'ghi789', @@ -143,6 +149,7 @@ class ListShortUrlsTest extends ApiTestCase 'title' => null, 'crawlable' => false, 'forwardQuery' => true, + 'hasRedirectRules' => false, ]; #[Test, DataProvider('provideFilteredLists')] @@ -258,6 +265,15 @@ class ListShortUrlsTest extends ApiTestCase yield [['searchTerm' => 'example.com'], [ self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; + yield [['domain' => 'example.com'], [ + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'valid_api_key']; + yield [['domain' => Domain::DEFAULT_AUTHORITY], [ + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_DOCS, + ], 'valid_api_key']; yield [[], [ self::SHORT_URL_CUSTOM_SLUG, self::SHORT_URL_META, diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index cf7cee0f..3761e113 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -21,6 +21,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => true, 'visitedUrl' => 'foo.com', 'type' => 'invalid_short_url', + 'redirectUrl' => null, ]; private const REGULAR_NOT_FOUND = [ 'referer' => 'https://s.test/foo/bar', @@ -30,6 +31,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'regular_404', + 'redirectUrl' => null, ]; private const BASE_URL = [ 'referer' => 'https://s.test', @@ -39,6 +41,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'base_url', + 'redirectUrl' => null, ]; #[Test, DataProvider('provideQueries')] diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index 0c0ce5ec..e02c9247 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -45,7 +45,7 @@ class ResolveShortUrlTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php index f096e411..6501ef13 100644 --- a/module/Rest/test-api/Action/SetRedirectRulesTest.php +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -96,6 +96,20 @@ class SetRedirectRulesTest extends ApiTestCase ], ], ]], 'invalid IP address')] + #[TestWith([[ + 'redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'geolocation-country-code', + 'matchKey' => null, + 'matchValue' => 'not a country code', + ], + ], + ], + ], + ]], 'invalid country code')] public function errorIsReturnedWhenInvalidDataIsProvided(array $bodyPayload): void { $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [ diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 8db002c4..658fe88a 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -21,7 +21,7 @@ class ShortUrlVisitsTest extends ApiTestCase #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, - ?string $domain, + string|null $domain, string $expectedDetail, string $apiKey, ): void { @@ -42,7 +42,7 @@ class ShortUrlVisitsTest extends ApiTestCase } #[Test, DataProvider('provideDomains')] - public function properVisitsAreReturnedWhenDomainIsProvided(?string $domain, int $expectedAmountOfVisits): void + public function properVisitsAreReturnedWhenDomainIsProvided(string|null $domain, int $expectedAmountOfVisits): void { $shortCode = 'ghi789'; $url = new Uri(sprintf('/short-urls/%s/visits', $shortCode)); diff --git a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php index 038e3f38..974ac0a5 100644 --- a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php +++ b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php @@ -13,7 +13,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class SingleStepCreateShortUrlTest extends ApiTestCase { #[Test, DataProvider('provideFormats')] - public function createsNewShortUrlWithExpectedResponse(?string $format, string $expectedContentType): void + public function createsNewShortUrlWithExpectedResponse(string|null $format, string $expectedContentType): void { $resp = $this->createShortUrl($format, 'valid_api_key'); @@ -43,7 +43,7 @@ class SingleStepCreateShortUrlTest extends ApiTestCase self::assertEquals('Invalid authorization', $payload['title']); } - private function createShortUrl(?string $format = 'json', ?string $apiKey = null): ResponseInterface + private function createShortUrl(string|null $format = 'json', string|null $apiKey = null): ResponseInterface { $query = [ 'longUrl' => 'https://app.shlink.io', diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index 949a80c3..c734e342 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; -use ReflectionObject; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -49,14 +48,9 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface $manager->flush(); } - private function buildApiKey(string $key, bool $enabled, ?Chronos $expiresAt = null): ApiKey + private function buildApiKey(string $key, bool $enabled, Chronos|null $expiresAt = null): ApiKey { - $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $expiresAt)); - $ref = new ReflectionObject($apiKey); - $keyProp = $ref->getProperty('key'); - $keyProp->setAccessible(true); - $keyProp->setValue($apiKey, $key); - + $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(key: $key, expirationDate: $expiresAt)); if (! $enabled) { $apiKey->disable(); } diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index 9972e3a8..e10b6dab 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -23,43 +23,55 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface { /** @var ShortUrl $abcShortUrl */ $abcShortUrl = $this->getReference('abc123_short_url'); - $manager->persist( - Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '44.55.66.77', '')), - ); $manager->persist(Visit::forValidShortUrl( $abcShortUrl, - new Visitor('shlink-tests-agent', 'https://google.com', '4.5.6.7', ''), + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '44.55.66.77'), + )); + $manager->persist(Visit::forValidShortUrl( + $abcShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://google.com', '4.5.6.7'), + )); + $manager->persist(Visit::forValidShortUrl( + $abcShortUrl, + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '1.2.3.4'), )); - $manager->persist(Visit::forValidShortUrl($abcShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); - $manager->persist( - Visit::forValidShortUrl($defShortUrl, new Visitor('cf-facebook', '', '127.0.0.1', '')), - ); - $manager->persist( - Visit::forValidShortUrl($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), - ); + $manager->persist(Visit::forValidShortUrl( + $defShortUrl, + Visitor::fromParams(userAgent: 'cf-facebook', remoteAddress: '127.0.0.1'), + )); + $manager->persist(Visit::forValidShortUrl( + $defShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''), + )); /** @var ShortUrl $ghiShortUrl */ $ghiShortUrl = $this->getReference('ghi789_short_url'); - $manager->persist(Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4', ''))); - $manager->persist( - Visit::forValidShortUrl($ghiShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '', '')), - ); + $manager->persist(Visit::forValidShortUrl( + $ghiShortUrl, + Visitor::fromParams(userAgent: 'shlink-tests-agent', remoteAddress: '1.2.3.4'), + )); + $manager->persist(Visit::forValidShortUrl( + $ghiShortUrl, + Visitor::fromParams('shlink-tests-agent', 'https://app.shlink.io', ''), + )); $manager->persist($this->setVisitDate( - fn () => Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')), + fn () => Visit::forBasePath(Visitor::fromParams('shlink-tests-agent', 'https://s.test', '1.2.3.4')), '2020-01-01', )); $manager->persist($this->setVisitDate( fn () => Visit::forRegularNotFound( - new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', ''), + Visitor::fromParams('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4'), ), '2020-02-01', )); $manager->persist($this->setVisitDate( - fn () => Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')), + fn () => Visit::forInvalidShortUrl( + Visitor::fromParams('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com'), + ), '2020-03-01', )); diff --git a/module/Rest/test-api/Utils/UrlBuilder.php b/module/Rest/test-api/Utils/UrlBuilder.php index 6de96a81..e61d6ad4 100644 --- a/module/Rest/test-api/Utils/UrlBuilder.php +++ b/module/Rest/test-api/Utils/UrlBuilder.php @@ -11,7 +11,7 @@ use function sprintf; class UrlBuilder { - public static function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string + public static function buildShortUrlPath(string $shortCode, string|null $domain, string $suffix = ''): string { $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); if ($domain !== null) { diff --git a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php index c19d8512..d0f6157d 100644 --- a/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php +++ b/module/Rest/test-db/ApiKey/Repository/ApiKeyRepositoryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Rest\ApiKey\Repository; use PHPUnit\Framework\Attributes\Test; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; @@ -24,9 +25,19 @@ class ApiKeyRepositoryTest extends DatabaseTestCase self::assertCount(0, $this->repo->findAll()); self::assertNotNull($this->repo->createInitialApiKey('initial_value')); self::assertCount(1, $this->repo->findAll()); - self::assertCount(1, $this->repo->findBy(['key' => 'initial_value'])); + self::assertCount(1, $this->repo->findBy(['key' => ApiKey::hashKey('initial_value')])); self::assertNull($this->repo->createInitialApiKey('another_one')); self::assertCount(1, $this->repo->findAll()); - self::assertCount(0, $this->repo->findBy(['key' => 'another_one'])); + self::assertCount(0, $this->repo->findBy(['key' => ApiKey::hashKey('another_one')])); + } + + #[Test] + public function nameExistsReturnsExpectedResult(): void + { + $this->getEntityManager()->persist(ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'foo'))); + $this->getEntityManager()->flush(); + + self::assertTrue($this->repo->nameExists('foo')); + self::assertFalse($this->repo->nameExists('bar')); } } diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 292c1748..45faf9f2 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -30,11 +30,11 @@ class DomainRedirectsRequestTest extends TestCase #[Test, DataProvider('provideValidData')] public function isProperlyCastToNotFoundRedirects( array $data, - ?NotFoundRedirectConfigInterface $defaults, + NotFoundRedirectConfigInterface|null $defaults, string $expectedAuthority, - ?string $expectedBaseUrlRedirect, - ?string $expectedRegular404Redirect, - ?string $expectedInvalidShortUrlRedirect, + string|null $expectedBaseUrlRedirect, + string|null $expectedRegular404Redirect, + string|null $expectedInvalidShortUrlRedirect, ): void { $request = DomainRedirectsRequest::fromRawData($data); $notFound = $request->toNotFoundRedirects($defaults); diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index ce4ad04f..69bfb56a 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -49,7 +49,7 @@ class MercureInfoActionTest extends TestCase } #[Test, DataProvider('provideDays')] - public function returnsExpectedInfoWhenEverythingIsOk(?int $days): void + public function returnsExpectedInfoWhenEverythingIsOk(int|null $days): void { $this->provider->expects($this->once())->method('buildSubscriptionToken')->willReturn('abc.123'); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index a3beba1b..ae99f0a9 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -39,11 +39,11 @@ class ListShortUrlsActionTest extends TestCase public function properListReturnsSuccessResponse( array $query, int $expectedPage, - ?string $expectedSearchTerm, + string|null $expectedSearchTerm, array $expectedTags, - ?string $expectedOrderBy, - ?string $startDate = null, - ?string $endDate = null, + string|null $expectedOrderBy, + string|null $startDate = null, + string|null $endDate = null, ): void { $apiKey = ApiKey::create(); $request = ServerRequestFactory::fromGlobals()->withQueryParams($query) diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 5d036097..7545cd6a 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -25,7 +25,7 @@ class DeleteTagsActionTest extends TestCase } #[Test, DataProvider('provideTags')] - public function processDelegatesIntoService(?array $tags): void + public function processDelegatesIntoService(array|null $tags): void { $request = (new ServerRequest()) ->withQueryParams(['tags' => $tags]) diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 73575baf..f83ee037 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -11,8 +11,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -53,7 +53,7 @@ class UpdateTagActionTest extends TestCase 'newName' => 'bar', ]); $this->tagService->expects($this->once())->method('renameTag')->with( - TagRenaming::fromNames('foo', 'bar'), + Renaming::fromNames('foo', 'bar'), $this->isInstanceOf(ApiKey::class), )->willReturn(new Tag('bar')); diff --git a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php index d60dae2e..d4a16573 100644 --- a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\DomainVisitsAction; @@ -49,7 +50,7 @@ class DomainVisitsActionTest extends TestCase public static function provideDomainAuthorities(): iterable { yield 'no default domain' => ['foo.com', 'foo.com']; - yield 'default domain' => ['the_default.com', 'DEFAULT']; - yield 'DEFAULT keyword' => ['DEFAULT', 'DEFAULT']; + yield 'default domain' => ['the_default.com', Domain::DEFAULT_AUTHORITY]; + yield 'DEFAULT keyword' => ['DEFAULT', Domain::DEFAULT_AUTHORITY]; } } diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index d5bdfef9..6892d3bd 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -35,7 +35,7 @@ class OrphanVisitsActionTest extends TestCase #[Test] public function requestIsHandled(): void { - $visitor = Visitor::emptyInstance(); + $visitor = Visitor::empty(); $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; $this->visitsHelper->expects($this->once())->method('orphanVisits')->with( $this->isInstanceOf(OrphanVisitsParams::class), diff --git a/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php b/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php new file mode 100644 index 00000000..dfd5b9f9 --- /dev/null +++ b/module/Rest/test/ApiKey/Model/ApiKeyMetaTest.php @@ -0,0 +1,35 @@ +name); + } + + public static function provideNames(): iterable + { + yield 'name' => [null, 'the name', static fn (ApiKeyMeta $meta) => 'the name']; + yield 'key' => ['the key', null, static fn (ApiKeyMeta $meta) => 'the key']; + yield 'generated key' => [null, null, static fn (ApiKeyMeta $meta) => sprintf( + '%s-****-****-****-************', + substr($meta->key, offset: 0, length: 8), + )]; + } +} diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 99a8b3e6..5c530480 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -130,18 +130,17 @@ class AuthenticationMiddlewareTest extends TestCase public function validApiKeyFallsBackToNextMiddleware(): void { $apiKey = ApiKey::create(); - $key = $apiKey->toString(); $request = ServerRequestFactory::fromGlobals() ->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route('bar', self::getDummyMiddleware()), []), ) - ->withHeader('X-Api-Key', $key); + ->withHeader('X-Api-Key', $apiKey->key); $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( + $this->apiKeyService->expects($this->once())->method('check')->with($apiKey->key)->willReturn( new ApiKeyCheckResult($apiKey), ); diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 100b146e..b74a435f 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -82,7 +82,7 @@ class CrossDomainMiddlewareTest extends TestCase #[Test, DataProvider('provideRouteResults')] public function optionsRequestParsesRouteMatchToDetermineAllowedMethods( - ?string $allowHeader, + string|null $allowHeader, string $expectedAllowedMethods, ): void { $originalResponse = new Response(); diff --git a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php index 74c06cd5..6c051fda 100644 --- a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response\EmptyResponse; -use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseFactoryInterface; @@ -21,13 +20,6 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase $this->factory = new EmptyResponseImplicitOptionsMiddlewareFactory(); } - #[Test] - public function serviceIsCreated(): void - { - $instance = ($this->factory)(); - self::assertInstanceOf(ImplicitOptionsMiddleware::class, $instance); - } - #[Test] public function responsePrototypeIsEmptyResponse(): void { diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index c847027c..7f4d0eba 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -40,7 +40,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase } #[Test, DataProvider('provideData')] - public function properResponseIsReturned(?string $accept, array $query, string $expectedContentType): void + public function properResponseIsReturned(string|null $accept, array $query, string $expectedContentType): void { $request = (new ServerRequest())->withQueryParams($query); if ($accept !== null) { diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 45364070..81bec4ea 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -12,40 +12,51 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; +use Shlinkio\Shlink\Core\Model\Renaming; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use Shlinkio\Shlink\Rest\Exception\ApiKeyConflictException; +use Shlinkio\Shlink\Rest\Exception\ApiKeyNotFoundException; use Shlinkio\Shlink\Rest\Service\ApiKeyService; +use function substr; + class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; private MockObject & EntityManager $em; - private MockObject & ApiKeyRepository $repo; + private MockObject & ApiKeyRepositoryInterface $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); - $this->repo = $this->createMock(ApiKeyRepository::class); - $this->service = new ApiKeyService($this->em); + $this->em->method('wrapInTransaction')->willReturnCallback(fn (callable $callback) => $callback()); + + $this->repo = $this->createMock(ApiKeyRepositoryInterface::class); + $this->service = new ApiKeyService($this->em, $this->repo); } /** * @param RoleDefinition[] $roles */ #[Test, DataProvider('provideCreationDate')] - public function apiKeyIsProperlyCreated(?Chronos $date, ?string $name, array $roles): void + public function apiKeyIsProperlyCreated(Chronos|null $date, string|null $name, array $roles): void { - $this->em->expects($this->once())->method('flush'); + $this->repo->expects($this->once())->method('nameExists')->with( + ! empty($name) ? $name : $this->isType('string'), + )->willReturn(false); $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); - $key = $this->service->create( - ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles), - ); + $meta = ApiKeyMeta::fromParams(name: $name, expirationDate: $date, roleDefinitions: $roles); + $key = $this->service->create($meta); self::assertEquals($date, $key->expirationDate); - self::assertEquals($name, $key->name); + self::assertEquals( + empty($name) ? substr($meta->key, 0, 8) . '-****-****-****-************' : $name, + $key->name, + ); foreach ($roles as $roleDefinition) { self::assertTrue($key->hasRole($roleDefinition->role)); } @@ -67,11 +78,39 @@ class ApiKeyServiceTest extends TestCase yield 'empty name' => [null, '', []]; } - #[Test, DataProvider('provideInvalidApiKeys')] - public function checkReturnsFalseForInvalidApiKeys(?ApiKey $invalidKey): void + #[Test] + public function autoGeneratedNameIsRegeneratedIfAlreadyExists(): void { - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($invalidKey); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); + $callCount = 0; + $this->repo->expects($this->exactly(3))->method('nameExists')->with( + $this->isType('string'), + )->willReturnCallback(function () use (&$callCount): bool { + $callCount++; + return $callCount < 3; + }); + $this->em->expects($this->once())->method('persist')->with($this->isInstanceOf(ApiKey::class)); + + $this->service->create(ApiKeyMeta::create()); + } + + #[Test] + public function exceptionIsThrownWhileCreatingIfExplicitlyProvidedNameIsInUse(): void + { + $this->repo->expects($this->once())->method('nameExists')->with('the_name')->willReturn(true); + $this->em->expects($this->never())->method('persist'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Another API key with name "the_name" already exists'); + + $this->service->create(ApiKeyMeta::fromParams(name: 'the_name')); + } + + #[Test, DataProvider('provideInvalidApiKeys')] + public function checkReturnsFalseForInvalidApiKeys(ApiKey|null $invalidKey): void + { + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + $invalidKey, + ); $result = $this->service->check('12345'); @@ -93,8 +132,9 @@ class ApiKeyServiceTest extends TestCase { $apiKey = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($apiKey); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); + $this->repo->expects($this->once())->method('findOneBy')->with(['key' => ApiKey::hashKey('12345')])->willReturn( + $apiKey, + ); $result = $this->service->check('12345'); @@ -102,38 +142,41 @@ class ApiKeyServiceTest extends TestCase self::assertSame($apiKey, $result->apiKey); } - #[Test] - public function disableThrowsExceptionWhenNoApiKeyIsFound(): void + #[Test, DataProvider('provideDisableArgs')] + public function disableThrowsExceptionWhenNoApiKeyIsFound(string $disableMethod, array $findOneByArg): void { - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn(null); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); + $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn(null); - $this->expectException(InvalidArgumentException::class); + $this->expectException(ApiKeyNotFoundException::class); - $this->service->disable('12345'); + $this->service->{$disableMethod}('12345'); } - #[Test] - public function disableReturnsDisabledApiKeyWhenFound(): void + #[Test, DataProvider('provideDisableArgs')] + public function disableReturnsDisabledApiKeyWhenFound(string $disableMethod, array $findOneByArg): void { $key = ApiKey::create(); - $this->repo->expects($this->once())->method('findOneBy')->with(['key' => '12345'])->willReturn($key); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); + $this->repo->expects($this->once())->method('findOneBy')->with($findOneByArg)->willReturn($key); $this->em->expects($this->once())->method('flush'); self::assertTrue($key->isEnabled()); - $returnedKey = $this->service->disable('12345'); + $returnedKey = $this->service->{$disableMethod}('12345'); self::assertFalse($key->isEnabled()); self::assertSame($key, $returnedKey); } + public static function provideDisableArgs(): iterable + { + yield 'disableByKey' => ['disableByKey', ['key' => ApiKey::hashKey('12345')]]; + yield 'disableByName' => ['disableByName', ['name' => '12345']]; + } + #[Test] public function listFindsAllApiKeys(): void { $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $this->repo->expects($this->once())->method('findBy')->with([])->willReturn($expectedApiKeys); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->listKeys(); @@ -146,7 +189,6 @@ class ApiKeyServiceTest extends TestCase $expectedApiKeys = [ApiKey::create(), ApiKey::create(), ApiKey::create()]; $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(enabledOnly: true); @@ -154,10 +196,9 @@ class ApiKeyServiceTest extends TestCase } #[Test, DataProvider('provideInitialApiKeys')] - public function createInitialDelegatesToRepository(?ApiKey $apiKey): void + public function createInitialDelegatesToRepository(ApiKey|null $apiKey): void { $this->repo->expects($this->once())->method('createInitialApiKey')->with('the_key')->willReturn($apiKey); - $this->em->method('getRepository')->with(ApiKey::class)->willReturn($this->repo); $result = $this->service->createInitial('the_key'); @@ -169,4 +210,62 @@ class ApiKeyServiceTest extends TestCase yield 'first api key' => [ApiKey::create()]; yield 'existing api keys' => [null]; } + + #[Test] + public function renameApiKeyThrowsExceptionIfApiKeyIsNotFound(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn(null); + $this->repo->expects($this->never())->method('nameExists'); + + $this->expectException(ApiKeyNotFoundException::class); + $this->expectExceptionMessage('API key with name "old" not found'); + + $this->service->renameApiKey($renaming); + } + + #[Test] + public function renameApiKeyReturnsApiKeyVerbatimIfBothNamesAreEqual(): void + { + $renaming = Renaming::fromNames(oldName: 'same_value', newName: 'same_value'); + $apiKey = ApiKey::create(); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'same_value'])->willReturn($apiKey); + $this->repo->expects($this->never())->method('nameExists'); + + $result = $this->service->renameApiKey($renaming); + + self::assertSame($apiKey, $result); + } + + #[Test] + public function renameApiKeyThrowsExceptionIfNewNameIsInUse(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + $apiKey = ApiKey::create(); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); + $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(true); + + $this->expectException(ApiKeyConflictException::class); + $this->expectExceptionMessage('An API key with name "new" already exists'); + + $this->service->renameApiKey($renaming); + } + + #[Test] + public function renameApiKeyReturnsApiKeyWithNewName(): void + { + $renaming = Renaming::fromNames(oldName: 'old', newName: 'new'); + $apiKey = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'old')); + + $this->repo->expects($this->once())->method('findOneBy')->with(['name' => 'old'])->willReturn($apiKey); + $this->repo->expects($this->once())->method('nameExists')->with('new')->willReturn(false); + + $result = $this->service->renameApiKey($renaming); + + self::assertSame($apiKey, $result); + self::assertEquals('new', $apiKey->name); + } } diff --git a/phpstan.neon b/phpstan.neon index 72c5ea6d..7b2a4718 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,7 +10,7 @@ parameters: - config - docker/config symfony: - console_application_loader: 'config/cli-app.php' + consoleApplicationLoader: 'config/cli-app.php' doctrine: repositoryClass: Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository objectManagerLoader: 'config/entity-manager.php' diff --git a/phpunit-api.xml b/phpunit-api.xml index a2f4def8..dc682322 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_api_tests.php" colors="true" cacheDirectory="build/.phpunit/api-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit-cli.xml b/phpunit-cli.xml index 1eaa0f28..186a9511 100644 --- a/phpunit-cli.xml +++ b/phpunit-cli.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_cli_tests.php" colors="true" cacheDirectory="build/.phpunit/cli-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit-db.xml b/phpunit-db.xml index 17e748b8..f63a2d7e 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -5,6 +5,8 @@ bootstrap="./config/test/bootstrap_db_tests.php" colors="true" cacheDirectory="build/.phpunit/db-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 30f2286d..4c7b9f10 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,7 @@ colors="true" cacheDirectory="build/.phpunit/unit-tests.cache" displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerDeprecations="true" >