diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index db3efacf..6bf9ad2c 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -27,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1 + php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.1 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-docker-image-build.yml b/.github/workflows/ci-docker-image-build.yml new file mode 100644 index 00000000..3a055f10 --- /dev/null +++ b/.github/workflows/ci-docker-image-build.yml @@ -0,0 +1,14 @@ +name: Build docker image + +on: + pull_request: + paths: + - 'Dockerfile' + +jobs: + build-docker-image: + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + - run: docker build -t shlink-docker-image:temp . diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index ac510c7d..5d8b1660 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -19,7 +19,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index f7e7b141..ba9fb991 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -25,7 +25,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca34c07d..c5749074 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,28 @@ name: Continuous integration on: - pull_request: null + pull_request: + paths-ignore: + - 'LICENSE' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' push: branches: - main - develop - 2.x + paths-ignore: + - 'LICENSE' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' jobs: static-analysis: @@ -20,7 +36,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -44,6 +60,8 @@ jobs: strategy: matrix: php-version: ['8.1', '8.2'] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - uses: actions/checkout@v3 - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres @@ -157,19 +175,3 @@ jobs: coverage-db coverage-api coverage-cli - - build-docker-image: - runs-on: ubuntu-22.04 - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - fetch-depth: 100 - - uses: marceloprado/has-changed-path@v1 - id: changed-dockerfile - with: - paths: ./Dockerfile - - if: ${{ steps.changed-dockerfile.outputs.changed == 'true' }} - run: docker build -t shlink-docker-image:temp . - - if: ${{ steps.changed-dockerfile.outputs.changed != 'true' }} - run: echo "Dockerfile didn't change. Skipped" diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/publish-docker-image.yml similarity index 83% rename from .github/workflows/docker-image-build.yml rename to .github/workflows/publish-docker-image.yml index 9eb682d6..7fb52fe1 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/publish-docker-image.yml @@ -4,6 +4,14 @@ on: push: branches: - develop + paths-ignore: + - 'LICENSE' + - '.*' + - '*.md' + - '*.xml' + - '*.yml*' + - '*.json5' + - '*.neon' tags: - 'v*' diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 792513be..8d8a4b0d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,7 +17,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 6e6cb925..dd5bfbde 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.12.0 + php-extensions: openswoole-4.12.1 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b9b2aa..bd0c8222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.5.0] - 2023-01-28 +### Added +* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type. + + For the moment, only `android`, `ios` and `desktop` can have their own specific long URL, and when the visitor cannot be matched against any of them, the regular long URL will be used. + + In the future, more granular device types could be added if appropriate (iOS tablet, android table, tablet, mobile phone, Linux, Mac, Windows, etc). + + In order to match the visitor's device, the `User-Agent` header is used. + +* [#1632](https://github.com/shlinkio/shlink/issues/1632) Added amount of bots, non-bots and total visits to the visits summary endpoint. +* [#1633](https://github.com/shlinkio/shlink/issues/1633) Added amount of bots, non-bots and total visits to the tag stats endpoint. +* [#1653](https://github.com/shlinkio/shlink/issues/1653) Added support for all HTTP methods in short URLs, together with two new redirect status codes, 307 and 308. + + Existing Shlink instances will continue to work the same. However, if you decide to set the redirect status codes as 307 or 308, Shlink will also return a redirect for short URLs even when the request method is different from `GET`. + + The status 308 is equivalent to 301, and 307 is equivalent to 302. The difference is that the spec requires the client to respect the original HTTP method when performing the redirect. With 301 and 302, some old clients might perform a `GET` request during the redirect, regardless the original request method. + +* [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`. +* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs. + + In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or `loosely`. + + Default value is `strict`, but if `loosely` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only. + +### Changed +* *Nothing* + +### Deprecated +* [#1676](https://github.com/shlinkio/shlink/issues/1676) Deprecated `GET /short-urls/shorten` endpoint. Use `POST /short-urls` to create short URLs instead. +* [#1678](https://github.com/shlinkio/shlink/issues/1678) Deprecated `validateUrl` option on URL creation/edition. + +### Removed +* *Nothing* + +### Fixed +* [#1639](https://github.com/shlinkio/shlink/issues/1639) Fixed 500 error returned when request body is not valid JSON, instead of a proper descriptive error. + + ## [3.4.0] - 2022-12-16 ### Added * [#1612](https://github.com/shlinkio/shlink/issues/1612) Allowed to filter short URLs out of lists, when `validUntil` date is in the past or have reached their maximum amount of visits. @@ -1428,7 +1467,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain. - Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. + Custom slugs can be created on multiple domains, allowing to share links like `https://s.test/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. When resolving a short URL to redirect end users, the following rules are applied: @@ -1891,7 +1930,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ```json { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", "dateCreated": "2016-05-01T20:34:16+02:00", "visitsCount": 1029, @@ -1958,7 +1997,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service. * [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range. - For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05` + For example: `https://s.test/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05` * [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances. @@ -2030,7 +2069,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This eases integration with third party services. - With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format. + With this feature, a simple request to a URL like `https://s.test/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format. ### Changed * *Nothing* @@ -2066,7 +2105,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection. - Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened. + Useful to track emails. Just add an image pointing to a URL like `https://s.test/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened. * [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests @@ -2347,7 +2386,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Added * [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL. - In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code` + In order to get the QR code URL, use a pattern like `https://s.test/abc123/qr-code` * [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory * [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging diff --git a/Dockerfile b/Dockerfile index 8c38653b..935c3d44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=openswoole ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ENV OPENSWOOLE_VERSION 4.12.0 +ENV OPENSWOOLE_VERSION 4.12.1 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/LICENSE b/LICENSE index 2a381d83..c245a4e0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2021 Alejandro Celaya +Copyright (c) 2016-2023 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c6dfa953..e721d8a1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png) -[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Continuous+integration%22) +[![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink) [![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) @@ -36,7 +36,7 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.1 +* PHP 8.1 or 8.2 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use openswoole. * xml extension is required if you want to generate QR codes in svg format. diff --git a/composer.json b/composer.json index ddf41fa5..ef47eced 100644 --- a/composer.json +++ b/composer.json @@ -40,16 +40,17 @@ "mezzio/mezzio-problem-details": "^1.7", "mezzio/mezzio-swoole": "^4.5", "mlocati/ip-lib": "^1.18", + "mobiledetect/mobiledetectlib": "^3.74", "ocramius/proxy-manager": "^2.14", "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.5", - "shlinkio/shlink-common": "^5.2", - "shlinkio/shlink-config": "^2.3", + "shlinkio/shlink-common": "^5.3", + "shlinkio/shlink-config": "^2.4", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^5.0", - "shlinkio/shlink-installer": "^8.2", + "shlinkio/shlink-installer": "^8.3", "shlinkio/shlink-ip-geolocation": "^3.2", "spiral/roadrunner": "^2.11", "spiral/roadrunner-jobs": "^2.5", @@ -73,7 +74,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.3", + "shlinkio/shlink-test-utils": "^3.4", "symfony/var-dumper": "^6.1", "veewee/composer-run-parallel": "^1.1" }, @@ -96,7 +97,8 @@ "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", "ShlinkioDbTest\\Shlink\\Rest\\": "module/Rest/test-db", "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", - "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db" + "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db", + "ShlinkioApiTest\\Shlink\\Core\\": "module/Core/test-api" }, "files": [ "config/test/constants.php" diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index fbc5fa03..029a50d6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -45,6 +45,7 @@ return [ Option\UrlShortener\AppendExtraPathConfigOption::class, Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\UrlShortener\EnableTrailingSlashConfigOption::class, + Option\UrlShortener\ShortUrlModeConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 426bb2ac..26a3c032 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -16,7 +16,7 @@ return [ ], 'redirects' => [ - 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), + 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE->value), 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv( DEFAULT_REDIRECT_CACHE_LIFETIME, ), diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 36cba24f..494e3cf2 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use function Shlinkio\Shlink\Config\getOpenswooleConfigFromEnv; + use const Shlinkio\Shlink\MIN_TASK_WORKERS; return (static function (): array { @@ -21,6 +23,7 @@ return (static function (): array { 'process-name' => 'shlink', 'options' => [ + ...getOpenswooleConfigFromEnv(), 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), ], diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index ec3c1409..2816577d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; @@ -12,6 +13,8 @@ return (static function (): array { (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), MIN_SHORT_CODES_LENGTH, ); + $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); + $mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; return [ @@ -25,6 +28,7 @@ return (static function (): array { 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), + 'mode' => $mode, ], ]; diff --git a/config/config.php b/config/config.php index 15a45348..e0ec6c23 100644 --- a/config/config.php +++ b/config/config.php @@ -15,6 +15,7 @@ use function class_exists; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Config\openswooleIsInstalled; use function Shlinkio\Shlink\Config\runningInRoadRunner; +use function Shlinkio\Shlink\Core\enumValues; use const PHP_SAPI; @@ -23,7 +24,7 @@ $enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoad return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values()) + ? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class)) : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, @@ -48,6 +49,7 @@ return (new ConfigAggregator\ConfigAggregator([ // Routes have to be loaded last new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), ], 'data/cache/app_config.php', [ - Core\Config\BasePathPrefixer::class, - Core\Config\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\BasePathPrefixer::class, + Core\Config\PostProcessor\MultiSegmentSlugProcessor::class, + Core\Config\PostProcessor\ShortUrlMethodsProcessor::class, ]))->getMergedConfig(); diff --git a/config/constants.php b/config/constants.php index d3d869c3..5c891a34 100644 --- a/config/constants.php +++ b/config/constants.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Fig\Http\Message\StatusCodeInterface; +use Shlinkio\Shlink\Core\Util\RedirectStatus; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; const MIN_SHORT_CODES_LENGTH = 4; -const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND; +const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4 const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag diff --git a/config/test/constants.php b/config/test/constants.php index c767abc9..bce232f3 100644 --- a/config/test/constants.php +++ b/config/test/constants.php @@ -6,3 +6,10 @@ namespace ShlinkioTest\Shlink; const API_TESTS_HOST = '127.0.0.1'; const API_TESTS_PORT = 9999; + +const ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/109.0.5414.86 Mobile Safari/537.36'; +const IOS_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 ' + . '(KHTML, like Gecko) FxiOS/109.0 Mobile/15E148 Safari/605.1.15'; +const DESKTOP_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like ' + . 'Gecko) Chrome/109.0.0.0 Safari/537.36 Edg/109.0.1518.61'; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 368a5f4e..ac62f8a6 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -84,7 +84,7 @@ $buildDbConnection = static function (): array { return match ($driver) { 'sqlite' => [ 'driver' => 'pdo_sqlite', - 'path' => sys_get_temp_dir() . '/shlink-tests.db', + 'memory' => true, ], 'postgres' => [ 'driver' => 'pdo_pgsql', @@ -131,7 +131,7 @@ return [ 'url_shortener' => [ 'domain' => [ 'schema' => 'http', - 'hostname' => 'doma.in', + 'hostname' => 's.test', ], ], diff --git a/data/infra/examples/apache-vhost.conf b/data/infra/examples/apache-vhost.conf index fbb7a18a..872001a3 100644 --- a/data/infra/examples/apache-vhost.conf +++ b/data/infra/examples/apache-vhost.conf @@ -1,5 +1,5 @@ - ServerName doma.in + ServerName s.test DocumentRoot "/path/to/shlink/public" diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 5e05481a..6cd4dd4e 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -1,5 +1,5 @@ server { - server_name doma.in; + server_name s.test; listen 80; root /path/to/shlink/public; index index.php; diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 21e7d95f..6cab2561 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.12.0 +ENV OPENSWOOLE_VERSION 4.12.1 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/migrations/Version20230103105343.php b/data/migrations/Version20230103105343.php new file mode 100644 index 00000000..c61a8a94 --- /dev/null +++ b/data/migrations/Version20230103105343.php @@ -0,0 +1,53 @@ +skipIf($schema->hasTable(self::TABLE_NAME)); + + $table = $schema->createTable(self::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('device_type', Types::STRING, ['length' => 255]); + $table->addColumn('long_url', Types::STRING, ['length' => 2048]); + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); + $schema->dropTable(self::TABLE_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index f3affecb..ca0064b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,7 +102,7 @@ services: shlink_db_mysql: container_name: shlink_db_mysql - image: mysql:5.7 + image: mysql:8.0 ports: - "3307:3306" volumes: @@ -175,7 +175,7 @@ services: shlink_mercure: container_name: shlink_mercure - image: dunglas/mercure:v0.13 + image: dunglas/mercure:v0.14 ports: - "3080:80" environment: diff --git a/docker/README.md b/docker/README.md index c1279b2d..629a9ee1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,7 +11,7 @@ It exposes a shlink instance served with [openswoole](https://openswoole.com/), The most basic way to run Shlink's docker image is by providing these mandatory env vars. -* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **doma.in**. +* `DEFAULT_DOMAIN`: The default short domain used for this shlink instance. For example **s.test**. * `IS_HTTPS_ENABLED`: Either **true** or **false**. Tells if Shlink is being served with HTTPs or not. * `GEOLITE_LICENSE_KEY`: Your GeoLite2 license key. [Learn more](https://shlink.io/documentation/geolite-license-key/) about this. @@ -21,7 +21,7 @@ To run shlink on top of a local docker service, and using an internal SQLite dat docker run \ --name shlink \ -p 8080:8080 \ - -e DEFAULT_DOMAIN=doma.in \ + -e DEFAULT_DOMAIN=s.test \ -e IS_HTTPS_ENABLED=true \ -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \ shlinkio/shlink:stable diff --git a/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md b/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md new file mode 100644 index 00000000..d81ba9d7 --- /dev/null +++ b/docs/adr/2023-01-06-support-any-http-method-in-short-urls.md @@ -0,0 +1,77 @@ +# Support any HTTP method in short URLs + +* Status: Accepted +* Date: 2023-01-06 + +## Context and problem statement + +There has been a report that Shlink behaves as if a short URL was not found when the request HTTP method is not `GET`. + +They want it to accept other methods so that they can do things like POSTing stuff that then gets "redirected" to the original URL. + +This presents two main problems: + +* Changing this could be considered a breaking change, in case someone is relying on this behavior (Shlink to only redirect on `GET`). +* Shlink currently supports two redirect statuses ([301](https://httpwg.org/specs/rfc9110.html#status.301) and [302](https://httpwg.org/specs/rfc9110.html#status.302)), which can be configured by the server admin. + + For historical reasons, a client might switch from the original method to `GET` when any of these is returned, not resulting in the desired behavior anyway. + + Instead, statuses [308](https://httpwg.org/specs/rfc9110.html#status.308) and [307](https://httpwg.org/specs/rfc9110.html#status.307) should be used. + +## Considered options + +There's actually two problems to solve here. Some combinations are implicitly required: + +* **To support other HTTP methods in short URLs** + * Start supporting all HTTP methods. + * Introduce a feature flag to allow users decide if they want to support all methods or just `GET`. +* **To support other redirects statuses (308 and 307)** + * Switch to status 308 and 307 and stop using 301 and 302. + * Allow users to configure which of the 4 status codes they want to use, insteadof just supporting 301 and 302. + * Allow users to configure between two combinations: 301+308 and 302+307, using 301 or 302 for `GET` requests, and 308 or 307 for the rest. + +> **Note** +> I asked on social networks, and these were the results (not too many answers though): +> * https://fosstodon.org/@shlinkio/109626773392324128 +> * https://twitter.com/shlinkio/status/1610347091741507585 + +## Decision outcome + +Because of backwards compatibility, it feels like the bets option is allowing to configure between 301, 302, 308 and 307. + +This has the benefit that we can keep existing behavior intact. Existing instances will continue working only on `GET`, with statuses 301 or 302. + +Anyone who wants to opt-in, can switch to 308 or 307, and the short URLs will transparently work on other HTTP methods in that case. + +The only drawback is that this difference in the behavior when 308 or 307 are configured needs to be documented, and explained in shlink-installer. + +## Pros and Cons of the Options + +### Start supporting all HTTP methods + +* Good: Because the change in code is pretty simple. +* Bad: Because it would be potentially a breaking change for anyone trusting current behavior for anything. + +### Support HTTP methods via feature flag + +* Good: because it would be safer for existing instances and opt-in for anyone interested in this change of behavior. +* Bad: Because it requires more changes in code. +* Bad: Because it requires a new config entry in the shlink-installer. + +### Switch to statuses 308 and 307 + +* Good: Because we keep supporting just two status codes. +* Bad: Because it requires applying mapping/transformation to convert old configurations. +* Bad: Because it requires changes in shlink-installer. + +### Allow users to configure between 301, 302, 308 and 307 + +* Good: Because it's fully backwards compatible with existing configs. +* Good: Because it would implicitly allow enabling all HTTP methods if 308 or 307 are selected, and keep only `GET` for 301 and 302, without the need for a separated feature flag. +* Bad: Because it requires dynamically supporting only `GET` or all methods, depending on the selected status. + +### Allow users to configure between 301+308 or 302+307 + +* Good: Because it would allow a more explicit redirects config, where values are not 301 and 302, but something like "permanent" and "temporary". +* Bad: Because it implicitly changes the behavior of existing instances, making them respond to redirects with a method other than `GET`, and with a status code other than the one they explicitly configured. +* Bad: because existing `REDIRECT_STATUS_CODE` env var might not make sense anymore, requiring a new one and logic to map from one to another. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7cfccdf7..9d87a0fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,6 +2,7 @@ Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome. +* [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md) * [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md) * [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md) * [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 3b59e8e5..d45dae2b 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -111,12 +111,19 @@ "type": "string", "description": "The original long URL." }, + "deviceLongUrls": { + "$ref": "#/components/schemas/DeviceLongUrls" + }, "dateCreated": { "type": "string", "format": "date-time", "description": "The date in which the short URL was created in ISO format." }, + "visitsSummary": { + "$ref": "#/components/schemas/VisitsSummary" + }, "visitsCount": { + "deprecated": true, "type": "integer", "description": "The number of visits that this short URL has received." }, @@ -146,10 +153,19 @@ }, "example": { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": "https://store.steampowered.com/android", + "ios": "https://store.steampowered.com/ios", + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", - "visitsCount": 328, + "visitsSummary": { + "total": 328, + "nonBots": 285, + "bots": 43 + }, "tags": [ "games", "tech" @@ -189,6 +205,42 @@ } } }, + "VisitsSummary": { + "type": "object", + "required": ["total", "nonBots", "bots"], + "properties": { + "total": { + "description": "The total amount of visits", + "type": "number" + }, + "nonBots": { + "description": "The amount of visits which were not identified as bots", + "type": "number" + }, + "bots": { + "description": "The amount of visits that were identified as potential bots", + "type": "number" + } + } + }, + "DeviceLongUrls": { + "type": "object", + "required": ["android", "ios", "desktop"], + "properties": { + "android": { + "description": "The long URL to redirect to when the short URL is visited from a device running Android", + "type": "string" + }, + "ios": { + "description": "The long URL to redirect to when the short URL is visited from a device running iOS", + "type": "string" + }, + "desktop": { + "description": "The long URL to redirect to when the short URL is visited from a desktop browser", + "type": "string" + } + } + }, "Visit": { "type": "object", "properties": { @@ -266,7 +318,7 @@ "timezone": "America/Los_Angeles" }, "potentialBot": false, - "visitedUrl": "https://doma.in", + "visitedUrl": "https://s.test", "type": "base_url" } }, diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json new file mode 100644 index 00000000..1a56d9ef --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrls.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "android": { + "description": "The long URL to redirect to when the short URL is visited from a device running Android", + "type": "string", + "nullable": false + }, + "ios": { + "description": "The long URL to redirect to when the short URL is visited from a device running iOS", + "type": "string", + "nullable": false + }, + "desktop": { + "description": "The long URL to redirect to when the short URL is visited from a desktop browser", + "type": "string", + "nullable": false + } + } +} diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json new file mode 100644 index 00000000..78f77e46 --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrlsEdit.json @@ -0,0 +1,17 @@ +{ + "type": "object", + "allOf": [{ + "$ref": "./DeviceLongUrls.json" + }], + "properties": { + "android": { + "nullable": true + }, + "ios": { + "nullable": true + }, + "desktop": { + "nullable": true + } + } +} diff --git a/docs/swagger/definitions/DeviceLongUrlsResp.json b/docs/swagger/definitions/DeviceLongUrlsResp.json new file mode 100644 index 00000000..95724581 --- /dev/null +++ b/docs/swagger/definitions/DeviceLongUrlsResp.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "required": ["android", "ios", "desktop"], + "allOf": [{ + "$ref": "./DeviceLongUrlsEdit.json" + }] +} diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index ab66f506..4060e2f2 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -4,6 +4,7 @@ "shortCode", "shortUrl", "longUrl", + "deviceLongUrls", "dateCreated", "visitsCount", "visitsSummary", @@ -27,6 +28,9 @@ "type": "string", "description": "The original long URL." }, + "deviceLongUrls": { + "$ref": "./DeviceLongUrlsResp.json" + }, "dateCreated": { "type": "string", "format": "date-time", @@ -38,7 +42,7 @@ "description": "**[DEPRECATED]** Use `visitsSummary.total` instead." }, "visitsSummary": { - "$ref": "./ShortUrlVisitsSummary.json" + "$ref": "./VisitsSummary.json" }, "tags": { "type": "array", diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index 94ef6135..ed3c3929 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -5,6 +5,9 @@ "description": "The long URL this short URL will redirect to", "type": "string" }, + "deviceLongUrls": { + "$ref": "./DeviceLongUrlsEdit.json" + }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", "type": "string", @@ -21,7 +24,8 @@ "nullable": true }, "validateUrl": { - "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", + "deprecated": true, + "description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`", "type": "boolean" }, "tags": { diff --git a/docs/swagger/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json index e881ce02..41de1068 100644 --- a/docs/swagger/definitions/TagInfo.json +++ b/docs/swagger/definitions/TagInfo.json @@ -1,5 +1,6 @@ { "type": "object", + "required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"], "properties": { "tag": { "type": "string", @@ -9,9 +10,13 @@ "type": "number", "description": "The amount of short URLs using this tag" }, - "userAgent": { + "visitsSummary": { + "$ref": "./VisitsSummary.json" + }, + "visitsCount": { + "deprecated": true, "type": "number", - "description": "The combined amount of visits received by short URLs with this tag" + "description": "**[DEPRECATED]** Use visitsSummary.total instead" } } } diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json index 2a97f597..2ed24375 100644 --- a/docs/swagger/definitions/VisitStats.json +++ b/docs/swagger/definitions/VisitStats.json @@ -1,14 +1,22 @@ { "type": "object", - "required": ["visitsCount", "orphanVisitsCount"], + "required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"], "properties": { + "nonOrphanVisits": { + "$ref": "./VisitsSummary.json" + }, + "orphanVisits": { + "$ref": "./VisitsSummary.json" + }, "visitsCount": { + "deprecated": true, "type": "number", - "description": "The total amount of visits received on any short URL." + "description": "**[DEPRECATED]** Use nonOrphanVisits.total instead" }, "orphanVisitsCount": { + "deprecated": true, "type": "number", - "description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)." + "description": "**[DEPRECATED]** Use orphanVisits.total instead" } } } diff --git a/docs/swagger/definitions/ShortUrlVisitsSummary.json b/docs/swagger/definitions/VisitsSummary.json similarity index 83% rename from docs/swagger/definitions/ShortUrlVisitsSummary.json rename to docs/swagger/definitions/VisitsSummary.json index 404b7a75..c59b2ccd 100644 --- a/docs/swagger/definitions/ShortUrlVisitsSummary.json +++ b/docs/swagger/definitions/VisitsSummary.json @@ -3,7 +3,7 @@ "required": ["total", "nonBots", "bots"], "properties": { "total": { - "description": "The total amount of visits that this short URL has received.", + "description": "The total amount of visits.", "type": "integer" }, "nonBots": { diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 8960234a..c226046f 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -161,8 +161,13 @@ "data": [ { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -184,8 +189,13 @@ }, { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": null, + "ios": "https://shlink.io/ios", + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -208,6 +218,11 @@ "shortCode": "123bA", "shortUrl": "https://example.com/123bA", "longUrl": "https://www.google.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2015-10-01T20:34:16+02:00", "visitsSummary": { "total": 25, @@ -281,6 +296,9 @@ "type": "object", "required": ["longUrl"], "properties": { + "deviceLongUrls": { + "$ref": "../definitions/DeviceLongUrls.json" + }, "customSlug": { "description": "A unique custom slug to be used instead of the generated short code", "type": "string" @@ -296,10 +314,6 @@ "shortCodeLength": { "description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided", "type": "number" - }, - "validateUrl": { - "description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config", - "type": "boolean" } } } @@ -318,8 +332,13 @@ }, "example": { "shortCode": "12C18", - "shortUrl": "https://doma.in/12C18", + "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 0, diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 254a88f2..cacb00bb 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -1,11 +1,12 @@ { "get": { "operationId": "shortenUrl", + "deprecated": true, "tags": [ "Short URLs" ], "summary": "Create a short URL", - "description": "Creates a short URL in a single API call. Useful for third party integrations.", + "description": "**[Deprecated]** Use [Create short URL](#/Short%20URLs/createShortUrl) instead", "parameters": [ { "$ref": "../parameters/version.json" @@ -52,7 +53,12 @@ }, "example": { "longUrl": "https://github.com/shlinkio/shlink", - "shortUrl": "https://doma.in/abc123", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, + "shortUrl": "https://s.test/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { @@ -78,7 +84,7 @@ "schema": { "type": "string" }, - "example": "https://doma.in/abc123" + "example": "https://s.test/abc123" } } }, diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 00577f4f..e639f362 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -38,8 +38,13 @@ }, "example": { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": null, + "ios": null, + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -160,8 +165,13 @@ }, "example": { "shortCode": "12Kb3", - "shortUrl": "https://doma.in/12Kb3", + "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", + "deviceLongUrls": { + "android": "https://shlink.io/android", + "ios": null, + "desktop": null + }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, diff --git a/docs/swagger/paths/v2_tags_stats.json b/docs/swagger/paths/v2_tags_stats.json index 91771335..150cf7b3 100644 --- a/docs/swagger/paths/v2_tags_stats.json +++ b/docs/swagger/paths/v2_tags_stats.json @@ -45,7 +45,7 @@ { "name": "orderBy", "in": "query", - "description": "To determine how to order the results.

**Important!** Ordering by `shortUrlsCount` or `visitsCount` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.
If you plan to order by any of these fields, it's worth loading the whole list with no pagination.", + "description": "To determine how to order the results.

**Important!** Ordering by `shortUrlsCount`, `visits` or `nonBotVisits` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.
If you plan to order by any of these fields, it's worth loading the whole list with no pagination.", "required": false, "schema": { "type": "string", @@ -54,8 +54,10 @@ "tag-DESC", "shortUrlsCount-ASC", "shortUrlsCount-DESC", - "visitsCount-ASC", - "visitsCount-DESC" + "visits-ASC", + "visits-DESC", + "nonBotVisits-ASC", + "nonBotVisits-DESC" ] } } @@ -73,7 +75,6 @@ "required": ["data"], "properties": { "data": { - "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", "type": "array", "items": { "$ref": "../definitions/TagInfo.json" @@ -92,12 +93,20 @@ { "tag": "games", "shortUrlsCount": 10, - "visitsCount": 521 + "visitsSummary": { + "total": 521, + "nonBots": 521, + "bots": 0 + } }, { "tag": "shlink", "shortUrlsCount": 7, - "visitsCount": 1087 + "visitsSummary": { + "total": 1087, + "nonBots": 1000, + "bots": 87 + } } ], "pagination": { diff --git a/docs/swagger/paths/v2_visits.json b/docs/swagger/paths/v2_visits.json index ded6ac6b..3db0ef67 100644 --- a/docs/swagger/paths/v2_visits.json +++ b/docs/swagger/paths/v2_visits.json @@ -31,8 +31,16 @@ }, "example": { "visits": { - "visitsCount": 1569874, - "orphanVisitsCount": 71345 + "nonOrphanVisits": { + "total": 64994, + "nonBots": 64986, + "bots": 8 + }, + "orphanVisits": { + "total": 37, + "nonBots": 34, + "bots": 3 + } } } } diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index 03d56553..b10ac37f 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -95,7 +95,7 @@ "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", "visitLocation": null, "potentialBot": false, - "visitedUrl": "https://doma.in", + "visitedUrl": "https://s.test", "type": "base_url" }, { @@ -112,7 +112,7 @@ "timezone": "America/Los_Angeles" }, "potentialBot": false, - "visitedUrl": "https://doma.in/foo", + "visitedUrl": "https://s.test/foo", "type": "invalid_short_url" }, { @@ -121,7 +121,7 @@ "userAgent": "some_web_crawler/1.4", "visitLocation": null, "potentialBot": true, - "visitedUrl": "https://doma.in/foo/bar/baz", + "visitedUrl": "https://s.test/foo/bar/baz", "type": "regular_404" } ], diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index f4cfc58a..6fb1001b 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -103,7 +102,7 @@ class CreateShortUrlCommand extends Command 'validate-url', null, InputOption::VALUE_NONE, - 'Forces the long URL to be validated, regardless what is globally configured.', + '[DEPRECATED] Makes the URL to be validated as publicly accessible.', ) ->addOption( 'crawlable', @@ -175,8 +174,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled, - ])); + ], $this->options)); $io->writeln([ sprintf('Processed long URL: %s', $longUrl), diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index cd820169..02116d79 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -46,7 +46,7 @@ class ListTagsCommand extends Command return map( $tags, - static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount], + static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsSummary->total], ); } } diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index cbb8affd..ceb5cbfd 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -15,7 +15,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc private function __construct(string $message, ?Throwable $previous = null) { - parent::__construct($message, 0, $previous); + parent::__construct($message, previous: $previous); } public static function withOlderDb(?Throwable $prev = null): self diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php index c98573a5..8b92d2f0 100644 --- a/module/CLI/test-cli/Command/ListShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -27,11 +27,11 @@ class ListShortUrlsTest extends CliTestCase | Short Code | Title | Short URL | Long URL | Date created | Visits count | +--------------------+---------------+-------------------------------------------+-----------------------------------------------------------------------------------------------------------+---------------------------+--------------+ | ghi789 | | http://example.com/ghi789 | https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/ | 2019-01-01T00:00:30+00:00 | 0 | - | custom | | http://doma.in/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 | - | def456 | | http://doma.in/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 | + | custom | | http://s.test/custom | https://shlink.io | 2019-01-01T00:00:20+00:00 | 0 | + | def456 | | http://s.test/def456 | https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/ | 2019-01-01T00:00:10+00:00 | 2 | | custom-with-domain | | http://some-domain.com/custom-with-domain | https://google.com | 2018-10-20T00:00:00+00:00 | 0 | - | abc123 | My cool title | http://doma.in/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 | - | ghi789 | | http://doma.in/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 | + | abc123 | My cool title | http://s.test/abc123 | https://shlink.io | 2018-05-01T00:00:00+00:00 | 3 | + | ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 | +--------------------+---------------+-------------------------------------------+---------------------------- Page 1 of 1 ------------------------------------------------------------------+---------------------------+--------------+ OUTPUT]; yield 'start date' => [['--start-date=2019-01'], << [['-e 2018-12-01'], << [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $domain = 'doma.in'; + $domain = 's.test'; $this->visitsHelper->expects($this->once())->method('visitsForDomain')->with( $domain, $this->anything(), diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 734089c9..69ce0c72 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -48,7 +48,7 @@ class CreateShortUrlCommandTest extends TestCase /** @test */ public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl); $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( 'stringified_short_url', @@ -98,11 +98,10 @@ class CreateShortUrlCommandTest extends TestCase /** @test */ public function properlyProcessesProvidedTags(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( - $this->callback(function (ShortUrlCreation $meta) { - $tags = $meta->getTags(); - Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); + $this->callback(function (ShortUrlCreation $creation) { + Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags); return true; }), )->willReturn($shortUrl); @@ -128,10 +127,10 @@ class CreateShortUrlCommandTest extends TestCase { $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedDomain) { - Assert::assertEquals($expectedDomain, $meta->getDomain()); + Assert::assertEquals($expectedDomain, $meta->domain); return true; }), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $input['longUrl'] = 'http://domain.com/foo/bar'; @@ -154,7 +153,7 @@ class CreateShortUrlCommandTest extends TestCase */ public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 8706699b..bd2be187 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -94,7 +94,7 @@ class GetShortUrlVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); $shortCode = 'abc123'; diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index be56cdee..b7255d0a 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 /** @test */ public function outputIsProperlyGenerated(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index 90147541..c780208a 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 /** @test */ public function outputIsProperlyGenerated(): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 518d9f45..44638249 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -66,7 +66,7 @@ class LocateVisitsCommandTest extends TestCase bool $expectWarningPrint, array $args, ): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $mockMethodBehavior = $this->invokeHelperMethods($visit, $location); @@ -113,7 +113,7 @@ class LocateVisitsCommandTest extends TestCase */ public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); @@ -140,7 +140,7 @@ class LocateVisitsCommandTest extends TestCase /** @test */ public function errorWhileLocatingIpIsDisplayed(): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); $location = VisitLocation::fromGeolocation(Location::emptyInstance()); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 48fe3cb2..4555bff1 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -136,8 +136,8 @@ return [ Options\DeleteShortUrlsOptions::class, ShortUrl\ShortUrlResolver::class, ], - ShortUrl\ShortUrlResolver::class => ['em'], - ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em'], + ShortUrl\ShortUrlResolver::class => ['em', Options\UrlShortenerOptions::class], + ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php new file mode 100644 index 00000000..8de69c18 --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php @@ -0,0 +1,41 @@ +setTable(determineTableName('device_long_urls', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + (new FieldBuilder($builder, [ + 'fieldName' => 'deviceType', + 'type' => Types::STRING, + 'enumType' => DeviceType::class, + ]))->columnName('device_type') + ->length(255) + ->build(); + + fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) + ->columnName('long_url') + ->length(2048) + ->build(); + + $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) + ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->build(); +}; 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 6b769f34..746ac3fd 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 @@ -24,7 +24,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) - ->columnName('original_url') + ->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯ ->length(2048) ->build(); @@ -67,6 +67,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->fetchExtraLazy() ->build(); + $builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class) + ->mappedBy('shortUrl') + ->cascadePersist() + ->orphanRemoval() + ->setIndexBy('deviceType') + ->build(); + $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index e7dff2ad..b6acbb35 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -4,33 +4,40 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; +use BackedEnum; use Cake\Chronos\Chronos; use Cake\Chronos\ChronosInterface; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\Filter\Word\CamelCaseToSeparator; +use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use function date_default_timezone_get; +use function Functional\map; use function Functional\reduce_left; use function is_array; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function strtolower; use function ucfirst; -function generateRandomShortCode(int $length): string +function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string { static $shortIdFactory; if ($shortIdFactory === null) { $shortIdFactory = new ShortIdFactory(); } - $alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $alphabet = $mode === ShortUrlMode::STRICT + ? '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + : '0123456789abcdefghijklmnopqrstuvwxyz'; return $shortIdFactory->generate($length, $alphabet)->serialize(); } @@ -143,7 +150,33 @@ function camelCaseToHumanFriendly(string $value): string return ucfirst($filter->filter($value)); } +function camelCaseToSnakeCase(string $value): string +{ + static $filter; + if ($filter === null) { + $filter = new CamelCaseToUnderscore(); + } + + return strtolower($filter->filter($value)); +} + function toProblemDetailsType(string $errorCode): string { return sprintf('https://shlink.io/api/error/%s', $errorCode); } + +/** + * @param class-string $enum + * @return string[] + */ +function enumValues(string $enum): array +{ + static $cache; + if ($cache === null) { + $cache = []; + } + + return $cache[$enum] ?? ( + $cache[$enum] = map($enum::cases(), static fn (BackedEnum $type) => (string) $type->value) + ); +} diff --git a/module/Core/src/Action/RedirectAction.php b/module/Core/src/Action/RedirectAction.php index 725e402d..942cf550 100644 --- a/module/Core/src/Action/RedirectAction.php +++ b/module/Core/src/Action/RedirectAction.php @@ -18,15 +18,15 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa public function __construct( ShortUrlResolverInterface $urlResolver, RequestTrackerInterface $requestTracker, - private ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private RedirectResponseHelperInterface $redirectResponseHelper, + private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private readonly RedirectResponseHelperInterface $redirectResponseHelper, ) { parent::__construct($urlResolver, $requestTracker); } protected function createSuccessResp(ShortUrl $shortUrl, ServerRequestInterface $request): Response { - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request->getQueryParams()); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 228a5921..44919415 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use function Functional\map; use function Shlinkio\Shlink\Config\env; enum EnvVars: string @@ -44,6 +43,7 @@ enum EnvVars: string case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; case BASE_PATH = 'BASE_PATH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; + case SHORT_URL_MODE = 'SHORT_URL_MODE'; case PORT = 'PORT'; case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; @@ -77,13 +77,4 @@ enum EnvVars: string { return $this->loadFromEnv() !== null; } - - /** - * @return string[] - */ - public static function values(): array - { - static $values; - return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value)); - } } diff --git a/module/Core/src/Config/BasePathPrefixer.php b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php similarity index 94% rename from module/Core/src/Config/BasePathPrefixer.php rename to module/Core/src/Config/PostProcessor/BasePathPrefixer.php index 4a306287..619e6056 100644 --- a/module/Core/src/Config/BasePathPrefixer.php +++ b/module/Core/src/Config/PostProcessor/BasePathPrefixer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Config; +namespace Shlinkio\Shlink\Core\Config\PostProcessor; use function Functional\map; diff --git a/module/Core/src/Config/MultiSegmentSlugProcessor.php b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php similarity index 66% rename from module/Core/src/Config/MultiSegmentSlugProcessor.php rename to module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php index b9cf2457..33945063 100644 --- a/module/Core/src/Config/MultiSegmentSlugProcessor.php +++ b/module/Core/src/Config/PostProcessor/MultiSegmentSlugProcessor.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Config; +namespace Shlinkio\Shlink\Core\Config\PostProcessor; use function Functional\map; use function str_replace; class MultiSegmentSlugProcessor { - private const SINGLE_SHORT_CODE_PATTERN = '{shortCode}'; - private const MULTI_SHORT_CODE_PATTERN = '{shortCode:.+}'; + private const SINGLE_SEGMENT_PATTERN = '{shortCode}'; + private const MULTI_SEGMENT_PATTERN = '{shortCode:.+}'; public function __invoke(array $config): array { @@ -21,7 +21,7 @@ class MultiSegmentSlugProcessor $config['routes'] = map($config['routes'] ?? [], static function (array $route): array { ['path' => $path] = $route; - $route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path); + $route['path'] = str_replace(self::SINGLE_SEGMENT_PATTERN, self::MULTI_SEGMENT_PATTERN, $path); return $route; }); diff --git a/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php new file mode 100644 index 00000000..05ecdb6c --- /dev/null +++ b/module/Core/src/Config/PostProcessor/ShortUrlMethodsProcessor.php @@ -0,0 +1,41 @@ + $route['name'] === RedirectAction::class, + ); + if (count($redirectRoutes) === 0) { + return $config; + } + + [$redirectRoute] = array_values($redirectRoutes); + $redirectStatus = RedirectStatus::tryFrom( + $config['redirects']['redirect_status_code'] ?? 0, + ) ?? DEFAULT_REDIRECT_STATUS_CODE; + $redirectRoute['allowed_methods'] = $redirectStatus->isLegacyStatus() + ? [RequestMethodInterface::METHOD_GET] + : Route::HTTP_METHOD_ANY; + + $config['routes'] = [...$rest, $redirectRoute]; + return $config; + } +} diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php index 3ecc9f03..703f77fd 100644 --- a/module/Core/src/Domain/DomainService.php +++ b/module/Core/src/Domain/DomainService.php @@ -51,7 +51,7 @@ class DomainService implements DomainServiceInterface $repo = $this->em->getRepository(Domain::class); $groups = group( $repo->findDomains($apiKey), - fn (Domain $domain) => $domain->getAuthority() === $this->defaultDomain ? 'default' : 'domains', + fn (Domain $domain) => $domain->authority === $this->defaultDomain ? 'default' : 'domains', ); return [first($groups['default'] ?? []), $groups['domains'] ?? []]; diff --git a/module/Core/src/Domain/Entity/Domain.php b/module/Core/src/Domain/Entity/Domain.php index ab33ae17..4e6ea865 100644 --- a/module/Core/src/Domain/Entity/Domain.php +++ b/module/Core/src/Domain/Entity/Domain.php @@ -15,7 +15,7 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec private ?string $regular404Redirect = null; private ?string $invalidShortUrlRedirect = null; - private function __construct(private string $authority) + private function __construct(public readonly string $authority) { } @@ -24,14 +24,9 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec return new self($authority); } - public function getAuthority(): string - { - return $this->authority; - } - public function jsonSerialize(): string { - return $this->getAuthority(); + return $this->authority; } public function invalidShortUrlRedirect(): ?string diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 72ea3e1f..53f2b6f7 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -20,7 +20,7 @@ final class DomainItem implements JsonSerializable public static function forNonDefaultDomain(Domain $domain): self { - return new self($domain->getAuthority(), $domain, false); + return new self($domain->authority, $domain, false); } public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self diff --git a/module/Core/src/Exception/MalformedBodyException.php b/module/Core/src/Exception/MalformedBodyException.php new file mode 100644 index 00000000..941730d1 --- /dev/null +++ b/module/Core/src/Exception/MalformedBodyException.php @@ -0,0 +1,29 @@ +detail = $e->getMessage(); + $e->title = 'Malformed request body'; + $e->type = toProblemDetailsType('malformed-request-body'); + $e->status = StatusCodeInterface::STATUS_BAD_REQUEST; + + return $e; + } +} diff --git a/module/Core/src/Model/DeviceType.php b/module/Core/src/Model/DeviceType.php new file mode 100644 index 00000000..df4a1838 --- /dev/null +++ b/module/Core/src/Model/DeviceType.php @@ -0,0 +1,28 @@ +is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only +// $detect->is('iOS') && ! $detect->isTablet() => self::IOS, // TODO To detect iPhone only +// $detect->is('androidOS') && $detect->isTablet() => self::ANDROID, // TODO To detect Android tablets +// $detect->is('androidOS') && ! $detect->isTablet() => self::ANDROID, // TODO To detect Android phones + $detect->is('iOS') => self::IOS, // Detects both iPhone and iPad + $detect->is('androidOS') => self::ANDROID, // Detects both android phones and android tablets + ! $detect->isMobile() && ! $detect->isTablet() => self::DESKTOP, + default => null, + }; + } +} diff --git a/module/Core/src/Options/RedirectOptions.php b/module/Core/src/Options/RedirectOptions.php index 9a1fedac..dd9f0a6d 100644 --- a/module/Core/src/Options/RedirectOptions.php +++ b/module/Core/src/Options/RedirectOptions.php @@ -4,23 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -use function Functional\contains; +use Fig\Http\Message\StatusCodeInterface; +use Shlinkio\Shlink\Core\Util\RedirectStatus; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; final class RedirectOptions { - public readonly int $redirectStatusCode; + public readonly RedirectStatus $redirectStatusCode; public readonly int $redirectCacheLifetime; public function __construct( - int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE, + int $redirectStatusCode = StatusCodeInterface::STATUS_FOUND, int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME, ) { - $this->redirectStatusCode = contains([301, 302], $redirectStatusCode) - ? $redirectStatusCode - : DEFAULT_REDIRECT_STATUS_CODE; + $this->redirectStatusCode = RedirectStatus::tryFrom($redirectStatusCode) ?? DEFAULT_REDIRECT_STATUS_CODE; $this->redirectCacheLifetime = $redirectCacheLifetime > 0 ? $redirectCacheLifetime : DEFAULT_REDIRECT_CACHE_LIFETIME; diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 9aacc085..98597bad 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; + use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class UrlShortenerOptions @@ -16,6 +18,12 @@ final class UrlShortenerOptions public readonly bool $appendExtraPath = false, public readonly bool $multiSegmentSlugsEnabled = false, public readonly bool $trailingSlashEnabled = false, + public readonly ShortUrlMode $mode = ShortUrlMode::STRICT, ) { } + + public function isLooselyMode(): bool + { + return $this->mode === ShortUrlMode::LOOSELY; + } } diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php new file mode 100644 index 00000000..668741e8 --- /dev/null +++ b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php @@ -0,0 +1,34 @@ +deviceType, $pair->longUrl); + } + + public function longUrl(): string + { + return $this->longUrl; + } + + public function updateLongUrl(string $longUrl): void + { + $this->longUrl = $longUrl; + } +} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 0ebdeb24..0328923a 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,8 +12,11 @@ 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\Model\DeviceType; +use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; @@ -23,7 +26,10 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; +use function array_fill_keys; use function count; +use function Functional\map; +use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; @@ -35,6 +41,8 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated; /** @var Collection */ private Collection $visits; + /** @var Collection */ + private Collection $deviceLongUrls; /** @var Collection */ private Collection $tags; private ?Chronos $validSince = null; @@ -55,11 +63,18 @@ class ShortUrl extends AbstractEntity { } - public static function createEmpty(): self + /** + * @internal + */ + public static function createFake(): self { - return self::create(ShortUrlCreation::createEmpty()); + return self::withLongUrl('foo'); } + /** + * @param non-empty-string $longUrl + * @internal + */ public static function withLongUrl(string $longUrl): self { return self::create(ShortUrlCreation::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl])); @@ -75,19 +90,26 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->tags = $relationResolver->resolveTags($creation->getTags()); - $instance->validSince = $creation->getValidSince(); - $instance->validUntil = $creation->getValidUntil(); - $instance->maxVisits = $creation->getMaxVisits(); + $instance->deviceLongUrls = new ArrayCollection(map( + $creation->deviceLongUrls, + fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), + )); + $instance->tags = $relationResolver->resolveTags($creation->tags); + $instance->validSince = $creation->validSince; + $instance->validUntil = $creation->validUntil; + $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); - $instance->shortCodeLength = $creation->getShortCodeLength(); - $instance->shortCode = $creation->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength); - $instance->domain = $relationResolver->resolveDomain($creation->getDomain()); - $instance->authorApiKey = $creation->getApiKey(); - $instance->title = $creation->getTitle(); - $instance->titleWasAutoResolved = $creation->titleWasAutoResolved(); - $instance->crawlable = $creation->isCrawlable(); - $instance->forwardQuery = $creation->forwardQuery(); + $instance->shortCodeLength = $creation->shortCodeLength; + $instance->shortCode = $creation->customSlug ?? generateRandomShortCode( + $instance->shortCodeLength, + $creation->shortUrlMode, + ); + $instance->domain = $relationResolver->resolveDomain($creation->domain); + $instance->authorApiKey = $creation->apiKey; + $instance->title = $creation->title; + $instance->titleWasAutoResolved = $creation->titleWasAutoResolved; + $instance->crawlable = $creation->crawlable; + $instance->forwardQuery = $creation->forwardQuery; return $instance; } @@ -120,11 +142,68 @@ class ShortUrl extends AbstractEntity return $instance; } + public function update( + ShortUrlEdition $shortUrlEdit, + ?ShortUrlRelationResolverInterface $relationResolver = null, + ): void { + if ($shortUrlEdit->validSinceWasProvided()) { + $this->validSince = $shortUrlEdit->validSince; + } + if ($shortUrlEdit->validUntilWasProvided()) { + $this->validUntil = $shortUrlEdit->validUntil; + } + if ($shortUrlEdit->maxVisitsWasProvided()) { + $this->maxVisits = $shortUrlEdit->maxVisits; + } + if ($shortUrlEdit->longUrlWasProvided()) { + $this->longUrl = $shortUrlEdit->longUrl ?? $this->longUrl; + } + if ($shortUrlEdit->tagsWereProvided()) { + $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); + $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags); + } + if ($shortUrlEdit->crawlableWasProvided()) { + $this->crawlable = $shortUrlEdit->crawlable; + } + if ( + $this->title === null + || $shortUrlEdit->titleWasProvided() + || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) + ) { + $this->title = $shortUrlEdit->title; + $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); + } + if ($shortUrlEdit->forwardQueryWasProvided()) { + $this->forwardQuery = $shortUrlEdit->forwardQuery; + } + + // Update device long URLs, removing, editing or creating where appropriate + foreach ($shortUrlEdit->devicesToRemove as $deviceType) { + $this->deviceLongUrls->remove($deviceType->value); + } + foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { + $key = $deviceLongUrlPair->deviceType->value; + $deviceLongUrl = $this->deviceLongUrls->get($key); + + if ($deviceLongUrl !== null) { + $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); + } else { + $this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); + } + } + } + public function getLongUrl(): string { return $this->longUrl; } + public function longUrlForDevice(?DeviceType $deviceType): string + { + $deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value); + return $deviceLongUrl?->longUrl() ?? $this->longUrl; + } + public function getShortCode(): string { return $this->shortCode; @@ -218,46 +297,10 @@ class ShortUrl extends AbstractEntity return $this->forwardQuery; } - public function update( - ShortUrlEdition $shortUrlEdit, - ?ShortUrlRelationResolverInterface $relationResolver = null, - ): void { - if ($shortUrlEdit->validSinceWasProvided()) { - $this->validSince = $shortUrlEdit->validSince(); - } - if ($shortUrlEdit->validUntilWasProvided()) { - $this->validUntil = $shortUrlEdit->validUntil(); - } - if ($shortUrlEdit->maxVisitsWasProvided()) { - $this->maxVisits = $shortUrlEdit->maxVisits(); - } - if ($shortUrlEdit->longUrlWasProvided()) { - $this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl; - } - if ($shortUrlEdit->tagsWereProvided()) { - $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver(); - $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags()); - } - if ($shortUrlEdit->crawlableWasProvided()) { - $this->crawlable = $shortUrlEdit->crawlable(); - } - if ( - $this->title === null - || $shortUrlEdit->titleWasProvided() - || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved()) - ) { - $this->title = $shortUrlEdit->title(); - $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved(); - } - if ($shortUrlEdit->forwardQueryWasProvided()) { - $this->forwardQuery = $shortUrlEdit->forwardQuery(); - } - } - /** * @throws ShortCodeCannotBeRegeneratedException */ - public function regenerateShortCode(): void + public function regenerateShortCode(ShortUrlMode $mode): void { // In ShortUrls where a custom slug was provided, throw error, unless it is an imported one if ($this->customSlugWasProvided && $this->importSource === null) { @@ -269,7 +312,7 @@ class ShortUrl extends AbstractEntity throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted(); } - $this->shortCode = generateRandomShortCode($this->shortCodeLength); + $this->shortCode = generateRandomShortCode($this->shortCodeLength, $mode); } public function isEnabled(): bool @@ -292,4 +335,14 @@ class ShortUrl extends AbstractEntity return true; } + + public function deviceLongUrls(): array + { + $data = array_fill_keys(enumValues(DeviceType::class), null); + foreach ($this->deviceLongUrls as $deviceUrl) { + $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); + } + + return $data; + } } diff --git a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php index 1f16f037..b428019e 100644 --- a/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortCodeUniquenessHelper.php @@ -5,14 +5,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $options, + ) { } public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool @@ -29,7 +32,7 @@ class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface return false; } - $shortUrlToBeCreated->regenerateShortCode(); + $shortUrlToBeCreated->regenerateShortCode($this->options->mode); return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index f003318d..c322f195 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -7,6 +7,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use GuzzleHttp\Psr7\Query; use Laminas\Stdlib\ArrayUtils; use League\Uri\Uri; +use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -14,13 +16,18 @@ use function sprintf; class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface { - public function __construct(private TrackingOptions $trackingOptions) + public function __construct(private readonly TrackingOptions $trackingOptions) { } - public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string - { - $uri = Uri::createFromString($shortUrl->getLongUrl()); + public function buildShortUrlRedirect( + ShortUrl $shortUrl, + ServerRequestInterface $request, + ?string $extraPath = null, + ): string { + $currentQuery = $request->getQueryParams(); + $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); + $uri = Uri::createFromString($shortUrl->longUrlForDevice($device)); $shouldForwardQuery = $shortUrl->forwardQuery(); return $uri diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php index 44bd9ccb..7f79e98a 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilderInterface.php @@ -4,9 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface ShortUrlRedirectionBuilderInterface { - public function buildShortUrlRedirect(ShortUrl $shortUrl, array $currentQuery, ?string $extraPath = null): string; + public function buildShortUrlRedirect( + ShortUrl $shortUrl, + ServerRequestInterface $request, + ?string $extraPath = null, + ): string; } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 719f82b8..9d21cb58 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,7 +11,7 @@ use function sprintf; class ShortUrlStringifier implements ShortUrlStringifierInterface { - public function __construct(private array $domainConfig, private string $basePath = '') + public function __construct(private readonly array $domainConfig, private readonly string $basePath = '') { } @@ -28,6 +28,6 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface private function resolveDomain(ShortUrl $shortUrl): string { - return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? ''; + return $shortUrl->getDomain()?->authority ?? $this->domainConfig['hostname'] ?? ''; } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 00eecc61..71963437 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -4,14 +4,23 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; +use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - public function __construct(private UrlValidatorInterface $urlValidator) + public function __construct(private readonly UrlValidatorInterface $urlValidator) { } + /** + * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 + * Move relevant logic from URL validator here. + * @template T of TitleResolutionModelInterface + * @param T $data + * @return T + * @throws InvalidUrlException + */ public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface { if ($data->hasTitle()) { diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php index 50022746..1861b451 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -9,6 +9,10 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException; interface ShortUrlTitleResolutionHelperInterface { /** + * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 + * @template T of TitleResolutionModelInterface + * @param T $data + * @return T * @throws InvalidUrlException */ public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface; diff --git a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php index 8af28706..4c56bfc1 100644 --- a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php +++ b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php @@ -10,7 +10,8 @@ interface TitleResolutionModelInterface public function getLongUrl(): string; + /** @deprecated */ public function doValidateUrl(): bool; - public function withResolvedTitle(string $title): self; + public function withResolvedTitle(string $title): static; } diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 66105779..c8f96bba 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -68,7 +68,6 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface int $shortCodeSegments = 1, ): ResponseInterface { $uri = $request->getUri(); - $query = $request->getQueryParams(); [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); @@ -76,7 +75,7 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface $shortUrl = $this->resolver->resolveEnabledShortUrl($identifier); $this->requestTracker->trackIfApplicable($shortUrl, $request); - $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) { diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php new file mode 100644 index 00000000..d017c7e5 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php @@ -0,0 +1,47 @@ + $map + * @return array{array, DeviceType[]} + */ + public static function fromMapToChangeSet(array $map): array + { + $typesWithNullUrl = group($map, static fn (?string $longUrl) => $longUrl === null ? 'remove' : 'keep'); + $deviceTypesToRemove = array_values(map( + $typesWithNullUrl['remove'] ?? [], + static fn ($_, string $deviceType) => DeviceType::from($deviceType), + )); + $pairsToKeep = map( + $typesWithNullUrl['keep'] ?? [], + fn (string $longUrl, string $deviceType) => self::fromRawTypeAndLongUrl($deviceType, $longUrl), + ); + + return [$pairsToKeep, $deviceTypesToRemove]; + } +} diff --git a/module/Core/src/ShortUrl/Model/OrderableField.php b/module/Core/src/ShortUrl/Model/OrderableField.php index 1c1c6338..ac1bc632 100644 --- a/module/Core/src/ShortUrl/Model/OrderableField.php +++ b/module/Core/src/ShortUrl/Model/OrderableField.php @@ -3,7 +3,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use function Functional\contains; -use function Functional\map; enum OrderableField: string { @@ -14,14 +13,6 @@ enum OrderableField: string case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - /** - * @return string[] - */ - public static function values(): array - { - return map(self::cases(), static fn (OrderableField $field) => $field->value); - } - public static function isBasicField(string $value): bool { return contains( diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index bbdd9ab0..43b39874 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -19,72 +20,91 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class ShortUrlCreation implements TitleResolutionModelInterface { - private string $longUrl; - private ?Chronos $validSince = null; - private ?Chronos $validUntil = null; - private ?string $customSlug = null; - private ?int $maxVisits = null; - private ?bool $findIfExists = null; - private ?string $domain = null; - private int $shortCodeLength = 5; - private bool $validateUrl = false; - private ?ApiKey $apiKey = null; - private array $tags = []; - private ?string $title = null; - private bool $titleWasAutoResolved = false; - private bool $crawlable = false; - private bool $forwardQuery = true; - - private function __construct() - { - } - - public static function createEmpty(): self - { - $instance = new self(); - $instance->longUrl = ''; - - return $instance; + /** + * @param string[] $tags + * @param DeviceLongUrlPair[] $deviceLongUrls + */ + private function __construct( + public readonly string $longUrl, + public readonly ShortUrlMode $shortUrlMode, + public readonly array $deviceLongUrls = [], + public readonly ?Chronos $validSince = null, + public readonly ?Chronos $validUntil = null, + public readonly ?string $customSlug = null, + public readonly ?int $maxVisits = null, + public readonly bool $findIfExists = false, + public readonly ?string $domain = null, + public readonly int $shortCodeLength = 5, + /** @deprecated */ + public readonly bool $validateUrl = false, + public readonly ?ApiKey $apiKey = null, + public readonly array $tags = [], + public readonly ?string $title = null, + public readonly bool $titleWasAutoResolved = false, + public readonly bool $crawlable = false, + public readonly bool $forwardQuery = true, + ) { } /** * @throws ValidationException */ - public static function fromRawData(array $data): self + public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self { - $instance = new self(); - $instance->validateAndInit($data); - - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $data): void - { - $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data); + $options = $options ?? new UrlShortenerOptions(); + $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); - $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); - $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; - $this->domain = getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN); - $this->shortCodeLength = getOptionalIntFromInputFilter( - $inputFilter, - ShortUrlInputFilter::SHORT_CODE_LENGTH, - ) ?? DEFAULT_SHORT_CODES_LENGTH; - $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY); - $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); - $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); - $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); - $this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; + [$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + ); + + return new self( + longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + shortUrlMode: $options->mode, + deviceLongUrls: $deviceLongUrls, + validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), + validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), + customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), + maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), + findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false, + domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN), + shortCodeLength: getOptionalIntFromInputFilter( + $inputFilter, + ShortUrlInputFilter::SHORT_CODE_LENGTH, + ) ?? DEFAULT_SHORT_CODES_LENGTH, + validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, + apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), + title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), + crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), + forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true, + ); + } + + public function withResolvedTitle(string $title): static + { + return new self( + longUrl: $this->longUrl, + shortUrlMode: $this->shortUrlMode, + deviceLongUrls: $this->deviceLongUrls, + validSince: $this->validSince, + validUntil: $this->validUntil, + customSlug: $this->customSlug, + maxVisits: $this->maxVisits, + findIfExists: $this->findIfExists, + domain: $this->domain, + shortCodeLength: $this->shortCodeLength, + validateUrl: $this->validateUrl, + apiKey: $this->apiKey, + tags: $this->tags, + title: $title, + titleWasAutoResolved: true, + crawlable: $this->crawlable, + forwardQuery: $this->forwardQuery, + ); } public function getLongUrl(): string @@ -92,115 +112,39 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return $this->longUrl; } - public function getValidSince(): ?Chronos - { - return $this->validSince; - } - public function hasValidSince(): bool { return $this->validSince !== null; } - public function getValidUntil(): ?Chronos - { - return $this->validUntil; - } - public function hasValidUntil(): bool { return $this->validUntil !== null; } - public function getCustomSlug(): ?string - { - return $this->customSlug; - } - public function hasCustomSlug(): bool { return $this->customSlug !== null; } - public function getMaxVisits(): ?int - { - return $this->maxVisits; - } - public function hasMaxVisits(): bool { return $this->maxVisits !== null; } - public function findIfExists(): bool - { - return (bool) $this->findIfExists; - } - public function hasDomain(): bool { return $this->domain !== null; } - public function getDomain(): ?string - { - return $this->domain; - } - - public function getShortCodeLength(): int - { - return $this->shortCodeLength; - } - + /** @deprecated */ public function doValidateUrl(): bool { return $this->validateUrl; } - public function getApiKey(): ?ApiKey - { - return $this->apiKey; - } - - /** - * @return string[] - */ - public function getTags(): array - { - return $this->tags; - } - - public function getTitle(): ?string - { - return $this->title; - } - public function hasTitle(): bool { return $this->title !== null; } - - public function titleWasAutoResolved(): bool - { - return $this->titleWasAutoResolved; - } - - public function withResolvedTitle(string $title): self - { - $copy = clone $this; - $copy->title = $title; - $copy->titleWasAutoResolved = true; - - return $copy; - } - - public function isCrawlable(): bool - { - return $this->crawlable; - } - - public function forwardQuery(): bool - { - return $this->forwardQuery; - } } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index fadc9b1e..fe92fae8 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -16,77 +17,102 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate; final class ShortUrlEdition implements TitleResolutionModelInterface { - private bool $longUrlPropWasProvided = false; - private ?string $longUrl = null; - private bool $validSincePropWasProvided = false; - private ?Chronos $validSince = null; - private bool $validUntilPropWasProvided = false; - private ?Chronos $validUntil = null; - private bool $maxVisitsPropWasProvided = false; - private ?int $maxVisits = null; - private bool $tagsPropWasProvided = false; - private array $tags = []; - private bool $titlePropWasProvided = false; - private ?string $title = null; - private bool $titleWasAutoResolved = false; - private bool $validateUrl = false; - private bool $crawlablePropWasProvided = false; - private bool $crawlable = false; - private bool $forwardQueryPropWasProvided = false; - private bool $forwardQuery = true; - - private function __construct() - { + /** + * @param string[] $tags + * @param DeviceLongUrlPair[] $deviceLongUrls + * @param DeviceType[] $devicesToRemove + */ + private function __construct( + private readonly bool $longUrlPropWasProvided = false, + public readonly ?string $longUrl = null, + public readonly array $deviceLongUrls = [], + public readonly array $devicesToRemove = [], + private readonly bool $validSincePropWasProvided = false, + public readonly ?Chronos $validSince = null, + private readonly bool $validUntilPropWasProvided = false, + public readonly ?Chronos $validUntil = null, + private readonly bool $maxVisitsPropWasProvided = false, + public readonly ?int $maxVisits = null, + private readonly bool $tagsPropWasProvided = false, + public readonly array $tags = [], + private readonly bool $titlePropWasProvided = false, + public readonly ?string $title = null, + public readonly bool $titleWasAutoResolved = false, + /** @deprecated */ + public readonly bool $validateUrl = false, + private readonly bool $crawlablePropWasProvided = false, + public readonly bool $crawlable = false, + private readonly bool $forwardQueryPropWasProvided = false, + public readonly bool $forwardQuery = true, + ) { } /** * @throws ValidationException */ public static function fromRawData(array $data): self - { - $instance = new self(); - $instance->validateAndInit($data); - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $data): void { $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } - $this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data); - $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data); - $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data); - $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data); - $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data); - $this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $data); - $this->crawlablePropWasProvided = array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data); - $this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); + [$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet( + $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], + ); - $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); - $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); - $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; - $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); - $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE); - $this->crawlable = $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE); - $this->forwardQuery = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true; + return new self( + longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data), + longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), + deviceLongUrls: $deviceLongUrls, + devicesToRemove: $devicesToRemove, + validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data), + validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), + validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data), + validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), + maxVisitsPropWasProvided: array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data), + maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), + tagsPropWasProvided: array_key_exists(ShortUrlInputFilter::TAGS, $data), + tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), + titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data), + title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), + validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, + crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data), + crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), + forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data), + forwardQuery: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::FORWARD_QUERY) ?? true, + ); } - public function longUrl(): ?string + public function withResolvedTitle(string $title): static { - return $this->longUrl; + return new self( + longUrlPropWasProvided: $this->longUrlPropWasProvided, + longUrl: $this->longUrl, + deviceLongUrls: $this->deviceLongUrls, + devicesToRemove: $this->devicesToRemove, + validSincePropWasProvided: $this->validSincePropWasProvided, + validSince: $this->validSince, + validUntilPropWasProvided: $this->validUntilPropWasProvided, + validUntil: $this->validUntil, + maxVisitsPropWasProvided: $this->maxVisitsPropWasProvided, + maxVisits: $this->maxVisits, + tagsPropWasProvided: $this->tagsPropWasProvided, + tags: $this->tags, + titlePropWasProvided: $this->titlePropWasProvided, + title: $title, + titleWasAutoResolved: true, + validateUrl: $this->validateUrl, + crawlablePropWasProvided: $this->crawlablePropWasProvided, + crawlable: $this->crawlable, + forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided, + forwardQuery: $this->forwardQuery, + ); } public function getLongUrl(): string { - return $this->longUrl() ?? ''; + return $this->longUrl ?? ''; } public function longUrlWasProvided(): bool @@ -94,54 +120,26 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->longUrlPropWasProvided && $this->longUrl !== null; } - public function validSince(): ?Chronos - { - return $this->validSince; - } - public function validSinceWasProvided(): bool { return $this->validSincePropWasProvided; } - public function validUntil(): ?Chronos - { - return $this->validUntil; - } - public function validUntilWasProvided(): bool { return $this->validUntilPropWasProvided; } - public function maxVisits(): ?int - { - return $this->maxVisits; - } - public function maxVisitsWasProvided(): bool { return $this->maxVisitsPropWasProvided; } - /** - * @return string[] - */ - public function tags(): array - { - return $this->tags; - } - public function tagsWereProvided(): bool { return $this->tagsPropWasProvided; } - public function title(): ?string - { - return $this->title; - } - public function titleWasProvided(): bool { return $this->titlePropWasProvided; @@ -157,35 +155,17 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->titleWasAutoResolved; } - public function withResolvedTitle(string $title): self - { - $copy = clone $this; - $copy->title = $title; - $copy->titleWasAutoResolved = true; - - return $copy; - } - + /** @deprecated */ public function doValidateUrl(): bool { return $this->validateUrl; } - public function crawlable(): bool - { - return $this->crawlable; - } - public function crawlableWasProvided(): bool { return $this->crawlablePropWasProvided; } - public function forwardQuery(): bool - { - return $this->forwardQuery; - } - public function forwardQueryWasProvided(): bool { return $this->forwardQueryPropWasProvided; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index d7b49c68..bb3b4af6 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -45,7 +45,7 @@ final class ShortUrlIdentifier public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); - $domainAuthority = $domain?->getAuthority(); + $domainAuthority = $domain?->authority; return new self($shortUrl->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlMode.php b/module/Core/src/ShortUrl/Model/ShortUrlMode.php new file mode 100644 index 00000000..41698e18 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/ShortUrlMode.php @@ -0,0 +1,9 @@ + $mode->value); - } } diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php new file mode 100644 index 00000000..ec0b30d3 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -0,0 +1,33 @@ +options->isLooselyMode() ? strtolower($value) : $value; + return (match ($this->options->multiSegmentSlugsEnabled) { + true => trim(str_replace(' ', '-', $value), '/'), + false => str_replace([' ', '/'], '-', $value), + }); + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php new file mode 100644 index 00000000..9fda1809 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php @@ -0,0 +1,57 @@ + 'Provided value is not an array.', + self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.', + self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.', + ]; + + public function __construct(private readonly ValidatorInterface $longUrlValidators) + { + parent::__construct(); + } + + public function isValid(mixed $value): bool + { + if (! is_array($value)) { + $this->error(self::NOT_ARRAY); + return false; + } + + $validValues = enumValues(DeviceType::class); + $keys = array_keys($value); + if (! every($keys, static fn ($key) => contains($validValues, $key))) { + $this->error(self::INVALID_DEVICE); + return false; + } + + $longUrls = array_values($value); + $result = every($longUrls, $this->longUrlValidators->isValid(...)); + if (! $result) { + $this->error(self::INVALID_LONG_URL); + } + + return $result; + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index a6c5627f..9c10d3ff 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -4,22 +4,22 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; -use DateTime; +use DateTimeInterface; use Laminas\Filter; -use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; -use Shlinkio\Shlink\Core\Config\EnvVars; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function is_string; -use function str_replace; use function substr; -use function trim; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; +/** + * @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl. + * Make it also dynamically add the relevant fields + */ class ShortUrlInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -32,6 +32,8 @@ class ShortUrlInputFilter extends InputFilter public const DOMAIN = 'domain'; public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const LONG_URL = 'longUrl'; + public const DEVICE_LONG_URLS = 'deviceLongUrls'; + /** @deprecated */ public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; public const TAGS = 'tags'; @@ -39,49 +41,60 @@ class ShortUrlInputFilter extends InputFilter public const CRAWLABLE = 'crawlable'; public const FORWARD_QUERY = 'forwardQuery'; - private function __construct(array $data, bool $requireLongUrl) + private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) { - $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); + $this->initialize($requireLongUrl, $options); $this->setData($data); } - public static function withRequiredLongUrl(array $data): self + public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self { - return new self($data, true); + return new self($data, true, $options); } public static function withNonRequiredLongUrl(array $data): self { - return new self($data, false); + return new self($data, false, new UrlShortenerOptions()); } - private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void + private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + $longUrlNotEmptyCommonOptions = [ Validator\NotEmpty::OBJECT, Validator\NotEmpty::SPACE, - Validator\NotEmpty::NULL, Validator\NotEmpty::EMPTY_ARRAY, Validator\NotEmpty::BOOLEAN, + Validator\NotEmpty::STRING, + ]; + + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ + ...$longUrlNotEmptyCommonOptions, + Validator\NotEmpty::NULL, ])); $this->add($longUrlInput); + $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); + $deviceLongUrlsInput->getValidatorChain()->attach( + new DeviceLongUrlsValidator(new Validator\NotEmpty([ + ...$longUrlNotEmptyCommonOptions, + ...($requireLongUrl ? [Validator\NotEmpty::NULL] : []), + ])), + ); + $this->add($deviceLongUrlsInput); + $validSince = $this->createInput(self::VALID_SINCE, false); - $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); + $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validSince); $validUntil = $this->createInput(self::VALID_UNTIL, false); - $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM])); + $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validUntil); - // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's - // empty, is by using the deprecated setContinueIfEmpty + // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value + // is by using the deprecated setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); - $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { - true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, - false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v, - })); + $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, Validator\NotEmpty::SPACE, @@ -102,10 +115,8 @@ class ShortUrlInputFilter extends InputFilter $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); - $apiKeyInput = new Input(self::API_KEY); - $apiKeyInput - ->setRequired(false) - ->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $apiKeyInput = $this->createInput(self::API_KEY, false); + $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); $this->add($apiKeyInput); $this->add($this->createTagsInput(self::TAGS, false)); diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index cb120e8e..d7cda41e 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -11,6 +11,8 @@ use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; +use function Shlinkio\Shlink\Core\enumValues; + class ShortUrlsParamsInputFilter extends InputFilter { use Validation\InputFactoryTrait; @@ -46,12 +48,12 @@ class ShortUrlsParamsInputFilter extends InputFilter $tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => TagsMode::values(), + 'haystack' => enumValues(TagsMode::class), 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); - $this->add($this->createOrderByInput(self::ORDER_BY, OrderableField::values())); + $this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class))); $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 5e95f777..05800abd 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -14,42 +14,41 @@ use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function count; +use function strtolower; class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl { // When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at // the bottom $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform(); $ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC'; + $isStrict = $shortUrlMode === ShortUrlMode::STRICT; - $dql = <<createQueryBuilder('s'); + $qb->leftJoin('s.domain', 'd') + ->where($qb->expr()->eq($isStrict ? 's.shortCode' : 'LOWER(s.shortCode)', ':shortCode')) + ->setParameter('shortCode', $isStrict ? $identifier->shortCode : strtolower($identifier->shortCode)) + ->andWhere($qb->expr()->orX( + $qb->expr()->isNull('s.domain'), + $qb->expr()->eq('d.authority', ':domain'), + )) + ->setParameter('domain', $identifier->domain); - $query = $this->getEntityManager()->createQuery($dql); - $query->setMaxResults(1) - ->setParameters([ - 'shortCode' => $identifier->shortCode, - 'domain' => $identifier->domain, - ]); - - // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one - // with no domain (if any), so it is safe to fetch 1 max result and we will get: + // Since we order by domain, we will have first the URL matching provided domain, followed by the one + // with no domain (if any), so it is safe to fetch 1 max result, and we will get: // * The short URL matching both the short code and the domain, or // * The short URL matching the short code but without any domain, or // * No short URL at all + $qb->orderBy('s.domain', $ordering) + ->setMaxResults(1); - return $query->getOneOrNullResult(); + return $qb->getQuery()->getOneOrNullResult(); } public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl @@ -101,45 +100,45 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb; } - public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl + public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl { $qb = $this->getEntityManager()->createQueryBuilder(); $qb->select('s') ->from(ShortUrl::class, 's') ->where($qb->expr()->eq('s.longUrl', ':longUrl')) - ->setParameter('longUrl', $meta->getLongUrl()) + ->setParameter('longUrl', $creation->longUrl) ->setMaxResults(1) ->orderBy('s.id'); - if ($meta->hasCustomSlug()) { + if ($creation->hasCustomSlug()) { $qb->andWhere($qb->expr()->eq('s.shortCode', ':slug')) - ->setParameter('slug', $meta->getCustomSlug()); + ->setParameter('slug', $creation->customSlug); } - if ($meta->hasMaxVisits()) { + if ($creation->hasMaxVisits()) { $qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits')) - ->setParameter('maxVisits', $meta->getMaxVisits()); + ->setParameter('maxVisits', $creation->maxVisits); } - if ($meta->hasValidSince()) { + if ($creation->hasValidSince()) { $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince')) - ->setParameter('validSince', $meta->getValidSince(), ChronosDateTimeType::CHRONOS_DATETIME); + ->setParameter('validSince', $creation->validSince, ChronosDateTimeType::CHRONOS_DATETIME); } - if ($meta->hasValidUntil()) { + if ($creation->hasValidUntil()) { $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) - ->setParameter('validUntil', $meta->getValidUntil(), ChronosDateTimeType::CHRONOS_DATETIME); + ->setParameter('validUntil', $creation->validUntil, ChronosDateTimeType::CHRONOS_DATETIME); } - if ($meta->hasDomain()) { + if ($creation->hasDomain()) { $qb->join('s.domain', 'd') ->andWhere($qb->expr()->eq('d.authority', ':domain')) - ->setParameter('domain', $meta->getDomain()); + ->setParameter('domain', $creation->domain); } - $apiKey = $meta->getApiKey(); + $apiKey = $creation->apiKey; if ($apiKey !== null) { $this->applySpecification($qb, $apiKey->spec(), 's'); } - $tags = $meta->getTags(); + $tags = $creation->tags; $tagsAmount = count($tags); if ($tagsAmount === 0) { return $qb->getQuery()->getOneOrNullResult(); diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php index cc574ac5..8af53cb9 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepositoryInterface.php @@ -10,11 +10,12 @@ use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface { - public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl; + public function findOneWithDomainFallback(ShortUrlIdentifier $identifier, ShortUrlMode $shortUrlMode): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; @@ -22,7 +23,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function shortCodeIsInUseWithLock(ShortUrlIdentifier $identifier, ?Specification $spec = null): bool; - public function findOneMatching(ShortUrlCreation $meta): ?ShortUrl; + public function findOneMatching(ShortUrlCreation $creation): ?ShortUrl; public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl; } diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 971ef932..db6721d5 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -49,7 +49,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collection */ public function resolveTags(array $tags): Collections\Collection { diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php index a71f2ccc..b5228214 100644 --- a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -14,7 +14,7 @@ interface ShortUrlRelationResolverInterface /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collection */ public function resolveTags(array $tags): Collection; } diff --git a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php index f25ff8a1..609a300c 100644 --- a/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/SimpleShortUrlRelationResolver.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\Common\Collections; -use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Tag\Entity\Tag; @@ -20,7 +19,7 @@ class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterfac /** * @param string[] $tags - * @return Collection|Tag[] + * @return Collections\Collection */ public function resolveTags(array $tags): Collections\Collection { diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 20ec930b..2c4f7bdc 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; @@ -13,8 +14,10 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlResolver implements ShortUrlResolverInterface { - public function __construct(private readonly EntityManagerInterface $em) - { + public function __construct( + private readonly EntityManagerInterface $em, + private readonly UrlShortenerOptions $urlShortenerOptions, + ) { } /** @@ -39,7 +42,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface { /** @var ShortUrlRepository $shortUrlRepo */ $shortUrlRepo = $this->em->getRepository(ShortUrl::class); - $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier); + $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier, $this->urlShortenerOptions->mode); if (! $shortUrl?->isEnabled()) { throw ShortUrlNotFoundException::fromNotFound($identifier); } diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 163989d8..95561fc5 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -34,7 +34,6 @@ class ShortUrlService implements ShortUrlServiceInterface ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { - /** @var ShortUrlEdition $shortUrlEdit */ $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index bd82cd9d..9de5c408 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Transformer; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use function Functional\invoke; use function Functional\invoke_if; @@ -26,6 +27,7 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => invoke($shortUrl->getTags(), '__toString'), 'meta' => $this->buildMeta($shortUrl), @@ -33,7 +35,10 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'title' => $shortUrl->title(), 'crawlable' => $shortUrl->crawlable(), 'forwardQuery' => $shortUrl->forwardQuery(), - 'visitsSummary' => $this->buildVisitsSummary($shortUrl), + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots( + $shortUrl->getVisitsCount(), + $shortUrl->nonBotVisitsCount(), + ), // Deprecated 'visitsCount' => $shortUrl->getVisitsCount(), @@ -52,16 +57,4 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'maxVisits' => $maxVisits, ]; } - - private function buildVisitsSummary(ShortUrl $shortUrl): array - { - $totalVisits = $shortUrl->getVisitsCount(); - $nonBotVisits = $shortUrl->nonBotVisitsCount(); - - return [ - 'total' => $totalVisits, - 'nonBots' => $nonBotVisits, - 'bots' => $totalVisits - $nonBotVisits, - ]; - } } diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index d3f54650..7477052f 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -31,22 +31,21 @@ class UrlShortener implements UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(ShortUrlCreation $meta): ShortUrl + public function shorten(ShortUrlCreation $creation): ShortUrl { // First, check if a short URL exists for all provided params - $existingShortUrl = $this->findExistingShortUrlIfExists($meta); + $existingShortUrl = $this->findExistingShortUrlIfExists($creation); if ($existingShortUrl !== null) { return $existingShortUrl; } - /** @var \Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation $meta */ - $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); + $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation); /** @var ShortUrl $newShortUrl */ - $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { - $shortUrl = ShortUrl::create($meta, $this->relationResolver); + $newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl { + $shortUrl = ShortUrl::create($creation, $this->relationResolver); - $this->verifyShortCodeUniqueness($meta, $shortUrl); + $this->verifyShortCodeUniqueness($creation, $shortUrl); $this->em->persist($shortUrl); return $shortUrl; @@ -57,15 +56,15 @@ class UrlShortener implements UrlShortenerInterface return $newShortUrl; } - private function findExistingShortUrlIfExists(ShortUrlCreation $meta): ?ShortUrl + private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl { - if (! $meta->findIfExists()) { + if (! $creation->findIfExists) { return null; } /** @var ShortUrlRepositoryInterface $repo */ $repo = $this->em->getRepository(ShortUrl::class); - return $repo->findOneMatching($meta); + return $repo->findOneMatching($creation); } private function verifyShortCodeUniqueness(ShortUrlCreation $meta, ShortUrl $shortUrlToBeCreated): void @@ -77,7 +76,7 @@ class UrlShortener implements UrlShortenerInterface if (! $couldBeMadeUnique) { $domain = $shortUrlToBeCreated->getDomain(); - $domainAuthority = $domain?->getAuthority(); + $domainAuthority = $domain?->authority; throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } diff --git a/module/Core/src/ShortUrl/UrlShortenerInterface.php b/module/Core/src/ShortUrl/UrlShortenerInterface.php index c15b7ebf..d7ae45f0 100644 --- a/module/Core/src/ShortUrl/UrlShortenerInterface.php +++ b/module/Core/src/ShortUrl/UrlShortenerInterface.php @@ -15,5 +15,5 @@ interface UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function shorten(ShortUrlCreation $meta): ShortUrl; + public function shorten(ShortUrlCreation $creation): ShortUrl; } diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php new file mode 100644 index 00000000..818099de --- /dev/null +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -0,0 +1,34 @@ + self::VISITS, + default => $parsed, + }; + + return camelCaseToSnakeCase($normalized->value); + } +} diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 5e71ea5b..4c0018b2 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -5,19 +5,29 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Model; use JsonSerializable; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; final class TagInfo implements JsonSerializable { + public readonly VisitsSummary $visitsSummary; + public function __construct( public readonly string $tag, public readonly int $shortUrlsCount, - public readonly int $visitsCount, + int $visitsCount, + ?int $nonBotVisitsCount = null, ) { + $this->visitsSummary = VisitsSummary::fromTotalAndNonBots($visitsCount, $nonBotVisitsCount ?? $visitsCount); } public static function fromRawData(array $data): self { - return new self($data['tag'], (int) $data['shortUrlsCount'], (int) $data['visitsCount']); + return new self( + $data['tag'], + (int) $data['shortUrlsCount'], + (int) $data['visits'], + isset($data['nonBotVisits']) ? (int) $data['nonBotVisits'] : null, + ); } public function jsonSerialize(): array @@ -25,7 +35,10 @@ final class TagInfo implements JsonSerializable return [ 'tag' => $this->tag, 'shortUrlsCount' => $this->shortUrlsCount, - 'visitsCount' => $this->visitsCount, + 'visitsSummary' => $this->visitsSummary, + + // Deprecated + 'visitsCount' => $this->visitsSummary->total, ]; } } diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 88e817ad..5dd9dcd9 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -8,6 +8,7 @@ use Doctrine\ORM\Query\ResultSetMappingBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Tag\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\OrderableField; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; @@ -16,7 +17,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function Functional\contains; use function Functional\map; use const PHP_INT_MAX; @@ -43,7 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito { $orderField = $filtering?->orderBy?->field; $orderDir = $filtering?->orderBy?->direction; - $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); + $orderMainQuery = $orderField !== null && OrderableField::isAggregateField($orderField); $conn = $this->getEntityManager()->getConnection(); $subQb = $this->createQueryBuilder('t'); @@ -72,12 +72,17 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 't.id_0 AS id', 't.name_1 AS name', 'COUNT(DISTINCT s.id) AS short_urls_count', - 'COUNT(DISTINCT v.id) AS visits_count', + 'COUNT(DISTINCT v.id) AS visits', // Native queries require snake_case for cross-db compatibility + 'COUNT(DISTINCT v2.id) AS non_bot_visits', ) ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) - ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id')) + ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('st.short_url_id', 'v.short_url_id')) + ->leftJoin('st', 'visits', 'v2', $nativeQb->expr()->and( // @phpstan-ignore-line + $nativeQb->expr()->eq('st.short_url_id', 'v2.short_url_id'), + $nativeQb->expr()->eq('v2.potential_bot', $conn->quote('0')), + )) ->groupBy('t.id_0', 't.name_1'); // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates @@ -92,10 +97,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito if ($orderMainQuery) { $nativeQb - ->orderBy( - $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', - $orderDir ?? 'ASC', - ) + ->orderBy(OrderableField::toSnakeCaseValidField($orderField), $orderDir ?? 'ASC') ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset ?? 0); } @@ -106,7 +108,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addScalarResult('name', 'tag'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); - $rsm->addScalarResult('visits_count', 'visitsCount'); + $rsm->addScalarResult('visits', 'visits'); + $rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 66e031d3..d50ced75 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -22,7 +22,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface { - public function __construct(private ORM\EntityManagerInterface $em) + public function __construct(private readonly ORM\EntityManagerInterface $em) { } diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php index dfc87480..01e581a7 100644 --- a/module/Core/src/Util/RedirectResponseHelper.php +++ b/module/Core/src/Util/RedirectResponseHelper.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Util; -use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\RedirectResponse; use Psr\Http\Message\ResponseInterface; use Shlinkio\Shlink\Core\Options\RedirectOptions; @@ -13,17 +12,17 @@ use function sprintf; class RedirectResponseHelper implements RedirectResponseHelperInterface { - public function __construct(private RedirectOptions $options) + public function __construct(private readonly RedirectOptions $options) { } public function buildRedirectResponse(string $location): ResponseInterface { $statusCode = $this->options->redirectStatusCode; - $headers = $statusCode === StatusCodeInterface::STATUS_FOUND ? [] : [ + $headers = ! $statusCode->allowsCache() ? [] : [ 'Cache-Control' => sprintf('private,max-age=%s', $this->options->redirectCacheLifetime), ]; - return new RedirectResponse($location, $statusCode, $headers); + return new RedirectResponse($location, $statusCode->value, $headers); } } diff --git a/module/Core/src/Util/RedirectStatus.php b/module/Core/src/Util/RedirectStatus.php new file mode 100644 index 00000000..76c047f4 --- /dev/null +++ b/module/Core/src/Util/RedirectStatus.php @@ -0,0 +1,23 @@ +validateUrlAndGetResponse($url); } + /** + * @deprecated + * @throws InvalidUrlException + */ public function validateUrlWithTitle(string $url, bool $doValidate): ?string { if (! $doValidate && ! $this->options->autoResolveTitles) { diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php index 299bd22a..cb38dc42 100644 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ b/module/Core/src/Util/UrlValidatorInterface.php @@ -6,14 +6,17 @@ namespace Shlinkio\Shlink\Core\Util; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +/** @deprecated */ interface UrlValidatorInterface { /** + * @deprecated * @throws InvalidUrlException */ public function validateUrl(string $url, bool $doValidate): void; /** + * @deprecated * @throws InvalidUrlException */ public function validateUrlWithTitle(string $url, bool $doValidate): ?string; diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index 475a25b5..adac34eb 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -8,15 +8,34 @@ use JsonSerializable; final class VisitsStats implements JsonSerializable { - public function __construct(private int $visitsCount, private int $orphanVisitsCount) - { + private readonly VisitsSummary $nonOrphanVisitsSummary; + private readonly VisitsSummary $orphanVisitsSummary; + + public function __construct( + int $nonOrphanVisitsTotal, + int $orphanVisitsTotal, + ?int $nonOrphanVisitsNonBots = null, + ?int $orphanVisitsNonBots = null, + ) { + $this->nonOrphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( + $nonOrphanVisitsTotal, + $nonOrphanVisitsNonBots ?? $nonOrphanVisitsTotal, + ); + $this->orphanVisitsSummary = VisitsSummary::fromTotalAndNonBots( + $orphanVisitsTotal, + $orphanVisitsNonBots ?? $orphanVisitsTotal, + ); } public function jsonSerialize(): array { return [ - 'visitsCount' => $this->visitsCount, - 'orphanVisitsCount' => $this->orphanVisitsCount, + 'nonOrphanVisits' => $this->nonOrphanVisitsSummary, + 'orphanVisits' => $this->orphanVisitsSummary, + + // Deprecated + 'visitsCount' => $this->nonOrphanVisitsSummary->total, + 'orphanVisitsCount' => $this->orphanVisitsSummary->total, ]; } } diff --git a/module/Core/src/Visit/Model/VisitsSummary.php b/module/Core/src/Visit/Model/VisitsSummary.php new file mode 100644 index 00000000..654170cb --- /dev/null +++ b/module/Core/src/Visit/Model/VisitsSummary.php @@ -0,0 +1,28 @@ + $this->total, + 'nonBots' => $this->nonBots, + 'bots' => $this->total - $this->nonBots, + ]; + } +} diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index c181665e..4e6e4daf 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -12,26 +12,25 @@ use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - public function __construct(private VisitRepositoryInterface $repo, private VisitsParams $params) + public function __construct(private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params) { } protected function doCount(): int { return $this->repo->countOrphanVisits(new VisitsCountFiltering( - $this->params->dateRange, - $this->params->excludeBots, + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, )); } public function getSlice(int $offset, int $length): iterable { return $this->repo->findOrphanVisits(new VisitsListFiltering( - $this->params->dateRange, - $this->params->excludeBots, - null, - $length, - $offset, + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, + limit: $length, + offset: $offset, )); } } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index f839a945..c445200e 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -18,6 +18,6 @@ class VisitsCountFiltering public static function withApiKey(?ApiKey $apiKey): self { - return new self(null, false, $apiKey); + return new self(apiKey: $apiKey); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index dcba7030..25f44921 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -32,7 +32,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsStatsHelper implements VisitsStatsHelperInterface { - public function __construct(private EntityManagerInterface $em) + public function __construct(private readonly EntityManagerInterface $em) { } @@ -42,8 +42,12 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $visitsRepo = $this->em->getRepository(Visit::class); return new VisitsStats( - $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + ), + orphanVisitsNonBots: $visitsRepo->countOrphanVisits(new VisitsCountFiltering(excludeBots: true)), ); } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index f97fc618..dd5fff91 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -15,9 +15,9 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; class VisitsTracker implements VisitsTrackerInterface { public function __construct( - private ORM\EntityManagerInterface $em, - private EventDispatcherInterface $eventDispatcher, - private TrackingOptions $options, + private readonly ORM\EntityManagerInterface $em, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly TrackingOptions $options, ) { } @@ -62,6 +62,9 @@ class VisitsTracker implements VisitsTrackerInterface $this->trackVisit($createVisit, $visitor); } + /** + * @param callable(Visitor $visitor): Visit $createVisit + */ private function trackVisit(callable $createVisit, Visitor $visitor): void { if ($this->options->disableTracking) { diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php new file mode 100644 index 00000000..73b6a1cc --- /dev/null +++ b/module/Core/test-api/Action/RedirectTest.php @@ -0,0 +1,38 @@ +callShortUrl('def456', $userAgent); + self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); + } + + public function provideUserAgents(): iterable + { + yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android']; + yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios']; + yield 'desktop' => [ + DESKTOP_USER_AGENT, + 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + ]; + yield 'unknown' => [ + null, + 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + ]; + } +} diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index c96d70ff..2b005947 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -131,7 +131,7 @@ class DomainRepositoryTest extends DatabaseTestCase { return ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->getAuthority(), 'apiKey' => $apiKey, 'longUrl' => 'foo'], + ['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo'], ), new class ($domain) implements ShortUrlRelationResolverInterface { public function __construct(private Domain $domain) diff --git a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php index c842bcb4..dd0cc4f0 100644 --- a/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/ShortUrl/Repository/ShortUrlRepositoryTest.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; use Cake\Chronos\Chronos; +use Doctrine\DBAL\Platforms\SQLServerPlatform; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -32,7 +34,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase /** @test */ public function findOneWithDomainFallbackReturnsProperData(): void { - $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'foo', 'longUrl' => 'foo'])); + $regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($regularOne); $withDomain = ShortUrl::create(ShortUrlCreation::fromRawData( @@ -41,7 +43,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($withDomain); $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( - ['domain' => 'doma.in', 'customSlug' => 'foo', 'longUrl' => 'foo_with_domain'], + ['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'foo_with_domain'], )); $this->getEntityManager()->persist($withDomainDuplicatingRegular); @@ -49,29 +51,53 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($regularOne->getShortCode()), + ShortUrlMode::STRICT, )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::LOOSELY, + )); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('fOo'), + ShortUrlMode::LOOSELY, + )); + // TODO MS is doing loosely checks always, making this fail. + if (! $this->getEntityManager()->getConnection()->getDatabasePlatform() instanceof SQLServerPlatform) { + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + ShortUrlMode::STRICT, + )); + } self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()), + ShortUrlMode::STRICT, )); self::assertSame($withDomain, $this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'example.com'), + ShortUrlMode::STRICT, )); self::assertSame( $withDomainDuplicatingRegular, $this->repo->findOneWithDomainFallback( - ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 's.test'), + ShortUrlMode::STRICT, ), ); self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain( $withDomainDuplicatingRegular->getShortCode(), 'other-domain.com', - ))); - self::assertNull($this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain('invalid'))); + ), ShortUrlMode::STRICT)); + self::assertNull($this->repo->findOneWithDomainFallback( + ShortUrlIdentifier::fromShortCodeAndDomain('invalid'), + ShortUrlMode::STRICT, + )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode()), + ShortUrlMode::STRICT, )); self::assertNull($this->repo->findOneWithDomainFallback( ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'other-domain.com'), + ShortUrlMode::STRICT, )); } @@ -84,7 +110,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrlWithoutDomain); $shortUrlWithDomain = ShortUrl::create( - ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), + ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); @@ -92,7 +118,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertTrue($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); self::assertFalse($this->repo->shortCodeIsInUse( - ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 's.test'), )); self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); self::assertFalse($this->repo->shortCodeIsInUse(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); @@ -100,7 +126,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), )); self::assertTrue($this->repo->shortCodeIsInUse( - ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 's.test'), )); } @@ -113,28 +139,27 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrlWithoutDomain); $shortUrlWithDomain = ShortUrl::create( - ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), + ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ); $this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->flush(); self::assertNotNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug'))); - self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('my-cool-slug', 's.test'))); self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('slug-not-in-use'))); self::assertNull($this->repo->findOne(ShortUrlIdentifier::fromShortCodeAndDomain('another-slug'))); self::assertNull($this->repo->findOne( ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'example.com'), )); self::assertNotNull($this->repo->findOne( - ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('another-slug', 's.test'), )); } /** @test */ public function findOneMatchingReturnsNullForNonExistingShortUrls(): void { - self::assertNull($this->repo->findOneMatching(ShortUrlCreation::createEmpty())); self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'foobar']))); self::assertNull($this->repo->findOneMatching( ShortUrlCreation::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]), @@ -175,7 +200,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl5); - $shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])); + $shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])); $this->getEntityManager()->persist($shortUrl6); $this->getEntityManager()->flush(); @@ -212,7 +237,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase ); self::assertSame( $shortUrl6, - $this->repo->findOneMatching(ShortUrlCreation::fromRawData(['domain' => 'doma.in', 'longUrl' => 'foo'])), + $this->repo->findOneMatching(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])), ); } @@ -270,7 +295,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'validSince' => $start, 'apiKey' => $apiKey, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ]), $this->relationResolver); @@ -313,7 +338,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], ])), @@ -322,7 +347,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority, 'apiKey' => $rightDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -332,7 +357,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -341,7 +366,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertNull( $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ 'validSince' => $start, - 'domain' => $rightDomain->getAuthority(), + 'domain' => $rightDomain->authority, 'apiKey' => $wrongDomainApiKey, 'longUrl' => 'foo', 'tags' => ['foo', 'bar'], @@ -379,16 +404,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); $this->getEntityManager()->persist($shortUrlWithoutDomain); - $shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 'doma.in'), true); + $shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 's.test'), true); $this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->flush(); self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug'))); - self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 'doma.in'))); + self::assertNotNull($this->repo->findOneByImportedUrl($buildImported('another-slug', 's.test'))); self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug'))); - self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 'doma.in'))); - self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 'doma.in'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('non-existing-slug', 's.test'))); + self::assertNull($this->repo->findOneByImportedUrl($buildImported('my-cool-slug', 's.test'))); self::assertNull($this->repo->findOneByImportedUrl($buildImported('another-slug'))); } } diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index ce0efff9..97873b20 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Tag\Entity\Tag; +use Shlinkio\Shlink\Core\Tag\Model\OrderableField; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -73,15 +74,15 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags] = array_chunk($names, 3); $secondUrlTags = [$names[0]]; - $metaWithTags = fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( - ['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey], + $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( + ['longUrl' => '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::emptyInstance())); + $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::botInstance())); $shortUrl2 = ShortUrl::create($metaWithTags($secondUrlTags, null), $this->relationResolver); $this->getEntityManager()->persist($shortUrl2); @@ -100,9 +101,10 @@ class TagRepositoryTest extends DatabaseTestCase $result = $this->repo->findTagsWithInfo($filtering); self::assertCount(count($expectedList), $result); - foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount]) { + foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount, $nonBotVisitsCount]) { self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount); - self::assertEquals($visitsCount, $result[$index]->visitsCount); + self::assertEquals($visitsCount, $result[$index]->visitsSummary->total); + self::assertEquals($nonBotVisitsCount, $result[$index]->visitsSummary->nonBots); self::assertEquals($tag, $result[$index]->tag); } } @@ -110,95 +112,112 @@ class TagRepositoryTest extends DatabaseTestCase public function provideFilters(): iterable { $defaultList = [ - ['another', 0, 0], - ['bar', 3, 3], - ['baz', 1, 3], - ['foo', 2, 4], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], ]; yield 'no filter' => [null, $defaultList]; yield 'empty filter' => [new TagsListFiltering(), $defaultList]; yield 'limit' => [new TagsListFiltering(2), [ - ['another', 0, 0], - ['bar', 3, 3], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], ]]; yield 'offset' => [new TagsListFiltering(null, 3), [ - ['foo', 2, 4], + ['foo', 2, 4, 3], ]]; yield 'limit and offset' => [new TagsListFiltering(2, 1), [ - ['bar', 3, 3], - ['baz', 1, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], ]]; yield 'search term' => [new TagsListFiltering(null, null, 'ba'), [ - ['bar', 3, 3], - ['baz', 1, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], ]]; yield 'ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::TAG->value, 'ASC'])), $defaultList, ]; - yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), [ - ['foo', 2, 4], - ['baz', 1, 3], - ['bar', 3, 3], - ['another', 0, 0], + yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::TAG->value, 'DESC'], + )), [ + ['foo', 2, 4, 3], + ['baz', 1, 3, 2], + ['bar', 3, 3, 2], + ['another', 0, 0, 0], ]]; yield 'short URLs count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::SHORT_URLS_COUNT->value, 'ASC'], + )), [ - ['another', 0, 0], - ['baz', 1, 3], - ['foo', 2, 4], - ['bar', 3, 3], + ['another', 0, 0, 0], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], ], ]; yield 'short URLs count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'DESC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], + )), [ - ['bar', 3, 3], - ['foo', 2, 4], - ['baz', 1, 3], - ['another', 0, 0], + ['bar', 3, 3, 2], + ['foo', 2, 4, 3], + ['baz', 1, 3, 2], + ['another', 0, 0, 0], ], ]; yield 'visits count ASC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'ASC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'ASC'])), [ - ['another', 0, 0], - ['bar', 3, 3], - ['baz', 1, 3], - ['foo', 2, 4], + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], + ], + ]; + yield 'non-bot visits count ASC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple( + [OrderableField::NON_BOT_VISITS->value, 'ASC'], + )), + [ + ['another', 0, 0, 0], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['foo', 2, 4, 3], ], ]; yield 'visits count DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + new TagsListFiltering(null, null, null, Ordering::fromTuple([OrderableField::VISITS->value, 'DESC'])), [ - ['foo', 2, 4], - ['bar', 3, 3], - ['baz', 1, 3], - ['another', 0, 0], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], + ['baz', 1, 3, 2], + ['another', 0, 0, 0], ], ]; yield 'visits count DESC ordering and limit' => [ - new TagsListFiltering(2, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + new TagsListFiltering(2, null, null, Ordering::fromTuple([OrderableField::VISITS_COUNT->value, 'DESC'])), [ - ['foo', 2, 4], - ['bar', 3, 3], + ['foo', 2, 4, 3], + ['bar', 3, 3, 2], ], ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ - ['bar', 2, 3], - ['baz', 1, 3], - ['foo', 1, 3], + ['bar', 2, 3, 2], + ['baz', 1, 3, 2], + ['foo', 1, 3, 2], ]]; yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromTuple( - ['shortUrls', 'DESC'], + [OrderableField::SHORT_URLS_COUNT->value, 'DESC'], ), ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ - ['foo', 1, 3], + ['bar', 2, 3, 2], ]]; } @@ -223,14 +242,14 @@ class TagRepositoryTest extends DatabaseTestCase [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); $shortUrl = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => '', 'tags' => $firstUrlTags]), + ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => 'longUrl', 'tags' => $firstUrlTags]), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); $shortUrl2 = ShortUrl::create( ShortUrlCreation::fromRawData( - ['domain' => $domain->getAuthority(), 'longUrl' => '', 'tags' => $secondUrlTags], + ['domain' => $domain->authority, 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], ), $this->relationResolver, ); diff --git a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php index 77f4c1e6..6b7c1e0d 100644 --- a/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitLocationRepositoryTest.php @@ -31,7 +31,7 @@ class VisitLocationRepositoryTest extends DatabaseTestCase */ public function findVisitsReturnsProperVisits(int $blockSize): void { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $this->getEntityManager()->persist($shortUrl); for ($i = 0; $i < 6; $i++) { diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index eb806208..f1fed415 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -208,17 +208,17 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findVisitsByDomainReturnsProperData(): void { - $this->createShortUrlsAndVisits('doma.in'); + $this->createShortUrlsAndVisits('s.test'); $this->getEntityManager()->flush(); self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); - self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering())); - self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); - self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + 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( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + 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( @@ -232,17 +232,17 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countVisitsByDomainReturnsProperData(): void { - $this->createShortUrlsAndVisits('doma.in'); + $this->createShortUrlsAndVisits('s.test'); $this->getEntityManager()->flush(); self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); - self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering())); - self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); - self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + 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( DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); - self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + 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( @@ -264,7 +264,9 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $apiKey1, 'domain' => $domain->getAuthority(), 'longUrl' => '']), + ShortUrlCreation::fromRawData( + ['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], + ), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl); @@ -272,12 +274,14 @@ class VisitRepositoryTest extends DatabaseTestCase $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey2); - $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => ''])); + $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl2); $this->createVisitsForShortUrl($shortUrl2, 5); $shortUrl3 = ShortUrl::create( - ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'domain' => $domain->getAuthority(), 'longUrl' => '']), + ShortUrlCreation::fromRawData( + ['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], + ), $this->relationResolver, ); $this->getEntityManager()->persist($shortUrl3); @@ -315,7 +319,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function findOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -364,7 +368,7 @@ class VisitRepositoryTest extends DatabaseTestCase /** @test */ public function countOrphanVisitsReturnsExpectedResult(): void { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); @@ -460,7 +464,7 @@ class VisitRepositoryTest extends DatabaseTestCase } /** - * @return array{string, string, \Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl} + * @return array{string, string, ShortUrl} */ private function createShortUrlsAndVisits( bool|string $withDomain = true, @@ -468,7 +472,7 @@ class VisitRepositoryTest extends DatabaseTestCase ?ApiKey $apiKey = null, ): array { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ - ShortUrlInputFilter::LONG_URL => '', + ShortUrlInputFilter::LONG_URL => 'longUrl', ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::API_KEY => $apiKey, ]), $this->relationResolver); @@ -482,7 +486,7 @@ class VisitRepositoryTest extends DatabaseTestCase $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => $shortCode, 'domain' => $domain, - 'longUrl' => '', + 'longUrl' => 'longUrl', ])); $this->getEntityManager()->persist($shortUrlWithDomain); $this->createVisitsForShortUrl($shortUrlWithDomain, 3); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 89c105c0..684e9217 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -56,7 +56,7 @@ class QrCodeActionTest extends TestCase $shortCode = 'abc123'; $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $delegate->expects($this->never())->method('handle'); @@ -78,7 +78,7 @@ class QrCodeActionTest extends TestCase $code = 'abc123'; $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); @@ -111,7 +111,7 @@ class QrCodeActionTest extends TestCase $code = 'abc123'; $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $delegate = $this->createMock(RequestHandlerInterface::class); $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); @@ -241,7 +241,7 @@ class QrCodeActionTest extends TestCase { return new QrCodeAction( $this->urlResolver, - new ShortUrlStringifier(['domain' => 'doma.in']), + new ShortUrlStringifier(['domain' => 's.test']), new NullLogger(), $options ?? new QrCodeOptions(), ); diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index ff4878de..6d4b1394 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\Config; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; -use function Functional\map; use function putenv; class EnvVarsTest extends TestCase @@ -59,11 +58,4 @@ class EnvVarsTest extends TestCase yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; } - - /** @test */ - public function allValuesCanBeListed(): void - { - $expected = map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value); - self::assertEquals(EnvVars::values(), $expected); - } } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index d2d03807..53531b15 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -61,16 +61,16 @@ class NotFoundRedirectResolverTest extends TestCase 'baseUrl', ]; yield 'base URL with domain placeholder' => [ - $uri = new Uri('https://doma.in'), + $uri = new Uri('https://s.test'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'), - 'https://redirect-here.com/doma.in', + 'https://redirect-here.com/s.test', ]; yield 'base URL with domain placeholder in query' => [ - $uri = new Uri('https://doma.in'), + $uri = new Uri('https://s.test'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), - 'https://redirect-here.com/?domain=doma.in', + 'https://redirect-here.com/?domain=s.test', ]; yield 'base URL without trailing slash' => [ $uri = new Uri(''), @@ -91,12 +91,12 @@ class NotFoundRedirectResolverTest extends TestCase 'https://redirect-here.com/?path=%2Ffoo%2Fbar', ]; yield 'regular 404 with multiple placeholders' => [ - $uri = new Uri('https://doma.in/foo/bar'), + $uri = new Uri('https://s.test/foo/bar'), $this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), new NotFoundRedirectOptions( regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', ), - 'https://redirect-here.com/foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar', + 'https://redirect-here.com/foo/bar/s.test/?d=s.test&p=%2Ffoo%2Fbar', ]; yield 'invalid short URL' => [ new Uri('/foo'), @@ -135,7 +135,7 @@ class NotFoundRedirectResolverTest extends TestCase RouteResult::class, RouteResult::fromRoute( new Route( - '', + 'foo', $this->createMock(MiddlewareInterface::class), ['GET'], $routeName, diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php similarity index 92% rename from module/Core/test/Config/BasePathPrefixerTest.php rename to module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php index 2298a59c..90c1449a 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/PostProcessor/BasePathPrefixerTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Config; +namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\BasePathPrefixer; +use Shlinkio\Shlink\Core\Config\PostProcessor\BasePathPrefixer; class BasePathPrefixerTest extends TestCase { diff --git a/module/Core/test/Config/MultiSegmentSlugProcessorTest.php b/module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php similarity index 92% rename from module/Core/test/Config/MultiSegmentSlugProcessorTest.php rename to module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php index 630a5d90..cef07a86 100644 --- a/module/Core/test/Config/MultiSegmentSlugProcessorTest.php +++ b/module/Core/test/Config/PostProcessor/MultiSegmentSlugProcessorTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Config; +namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\MultiSegmentSlugProcessor; +use Shlinkio\Shlink\Core\Config\PostProcessor\MultiSegmentSlugProcessor; class MultiSegmentSlugProcessorTest extends TestCase { diff --git a/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php new file mode 100644 index 00000000..b73253f7 --- /dev/null +++ b/module/Core/test/Config/PostProcessor/ShortUrlMethodsProcessorTest.php @@ -0,0 +1,105 @@ +processor = new ShortUrlMethodsProcessor(); + } + + /** + * @test + * @dataProvider provideConfigs + */ + public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods( + array $config, + ?array $expectedRoutes, + ): void { + self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null); + } + + public function provideConfigs(): iterable + { + $buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[ + 'routes' => [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ], + 'redirects' => [ + 'redirect_status_code' => $status, + ], + ], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => $expectedAllowedMethods, + ], + ]]; + + yield 'empty config' => [[], null]; + yield 'empty routes' => [['routes' => []], []]; + yield 'no redirects route' => [['routes' => $routes = [ + ['name' => 'foo'], + ['name' => 'bar'], + ]], $routes]; + yield 'one redirects route' => [['routes' => [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'one redirects route in different location' => [['routes' => [ + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['POST'], + ], + ['name' => 'foo'], + ['name' => 'bar'], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'multiple redirects routes' => [['routes' => [ + ['name' => RedirectAction::class], + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => RedirectAction::class], + ['name' => RedirectAction::class], + ]], [ + ['name' => 'foo'], + ['name' => 'bar'], + [ + 'name' => RedirectAction::class, + 'allowed_methods' => ['GET'], + ], + ]]; + yield 'one redirects route with invalid status code' => $buildConfigWithStatus(500, ['GET']); + yield 'one redirects route with 302 status code' => $buildConfigWithStatus(302, ['GET']); + yield 'one redirects route with 301 status code' => $buildConfigWithStatus(301, ['GET']); + yield 'one redirects route with 307 status code' => $buildConfigWithStatus(307, Route::HTTP_METHOD_ANY); + yield 'one redirects route with 308 status code' => $buildConfigWithStatus(308, Route::HTTP_METHOD_ANY); + } +} diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index aa6302a3..800dc4e0 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -55,7 +55,7 @@ class NotFoundTemplateHandlerTest extends TestCase RouteResult::class, RouteResult::fromRoute( new Route( - '', + 'foo', $this->createMock(MiddlewareInterface::class), ['GET'], RedirectAction::class, diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index cad6d164..d538bff0 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -70,7 +70,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + 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); @@ -89,7 +89,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + 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); @@ -110,7 +110,7 @@ class LocateVisitTest extends TestCase { $event = new UrlVisited('123'); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn( - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + 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); @@ -148,7 +148,7 @@ class LocateVisitTest extends TestCase public function provideNonLocatableVisits(): iterable { - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); yield 'null IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', null, ''))]; yield 'empty IP' => [Visit::forValidShortUrl($shortUrl, new Visitor('', '', '', ''))]; @@ -183,11 +183,11 @@ class LocateVisitTest extends TestCase public function provideIpAddresses(): iterable { yield 'no original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')), null, ]; yield 'original IP address' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', '')), + 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']; diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php index c42bd915..855f6c14 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -57,7 +57,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase /** @test */ public function expectedNotificationIsPublished(): void { - $shortUrl = ShortUrl::withLongUrl(''); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, '123')->willReturn($shortUrl); @@ -74,7 +74,7 @@ class NotifyNewShortUrlToMercureTest extends TestCase /** @test */ public function messageIsPrintedIfPublishingFails(): void { - $shortUrl = ShortUrl::withLongUrl(''); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $update = Update::forTopicAndPayload('', []); $e = new Exception('Error'); diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 1cecada7..23450fd3 100644 --- a/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -59,7 +59,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentWhenVisitIsFound(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $update = Update::forTopicAndPayload('', []); $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); @@ -79,7 +79,7 @@ class NotifyVisitToMercureTest extends TestCase public function debugIsLoggedWhenExceptionIsThrown(): void { $visitId = '123'; - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 7a5cb888..17b26a74 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -123,7 +123,7 @@ class NotifyVisitToWebHooksTest extends TestCase public function provideVisits(): iterable { yield 'regular visit' => [ - Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), ['shortUrl', 'visit'], ]; yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit'],]; diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index c7a4ecd0..924996f9 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; @@ -37,7 +38,7 @@ class PublishingUpdatesGeneratorTest extends TestCase { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', - 'longUrl' => '', + 'longUrl' => 'longUrl', 'title' => $title, ])); $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); @@ -50,7 +51,8 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), - 'longUrl' => '', + 'longUrl' => 'longUrl', + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], @@ -63,11 +65,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $title, 'crawlable' => false, 'forwardQuery' => true, - 'visitsSummary' => [ - 'total' => 0, - 'nonBots' => 0, - 'bots' => 0, - ], + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), ], 'visit' => [ 'referer' => '', @@ -121,7 +119,7 @@ class PublishingUpdatesGeneratorTest extends TestCase { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'customSlug' => 'foo', - 'longUrl' => '', + 'longUrl' => 'longUrl', 'title' => 'The title', ])); @@ -131,7 +129,8 @@ class PublishingUpdatesGeneratorTest extends TestCase self::assertEquals(['shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), - 'longUrl' => '', + 'longUrl' => 'longUrl', + 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'visitsCount' => 0, 'tags' => [], @@ -144,11 +143,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'title' => $shortUrl->title(), 'crawlable' => false, 'forwardQuery' => true, - 'visitsSummary' => [ - 'total' => 0, - 'nonBots' => 0, - 'bots' => 0, - ], + 'visitsSummary' => VisitsSummary::fromTotalAndNonBots(0, 0), ]], $update->payload); } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php index 764f7949..52e9630d 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -68,7 +68,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), @@ -88,7 +88,7 @@ class NotifyNewShortUrlToRabbitMqTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 8b7b392c..6211ad2b 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -159,7 +159,7 @@ class NotifyVisitToRabbitMqTest extends TestCase { yield 'legacy non-orphan visit' => [ true, - $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()), noop(...), function (MockObject & PublishingHelperInterface $helper) use ($visit): void { $helper->method('publishUpdate')->with($this->callback(function (Update $update) use ($visit): bool { @@ -190,7 +190,7 @@ class NotifyVisitToRabbitMqTest extends TestCase ]; yield 'non-legacy non-orphan visit' => [ false, - Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + Visit::forValidShortUrl(ShortUrl::withLongUrl('longUrl'), Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); $updatesGenerator->expects($this->never())->method('newOrphanVisitUpdate'); diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php index 0b5dfd27..a913de15 100644 --- a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -55,7 +55,7 @@ class NotifyNewShortUrlToRedisTest extends TestCase $shortUrlId = '123'; $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); $this->em->expects($this->once())->method('find')->with(ShortUrl::class, $shortUrlId)->willReturn( - ShortUrl::withLongUrl(''), + ShortUrl::withLongUrl('longUrl'), ); $this->updatesGenerator->expects($this->once())->method('newShortUrlUpdate')->with( $this->isInstanceOf(ShortUrl::class), diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index df1edaaa..bfdce269 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -57,14 +57,14 @@ class DeleteShortUrlExceptionTest extends TestCase { $e = DeleteShortUrlException::fromVisitsThreshold( 10, - ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 'doma.in'), + ShortUrlIdentifier::fromShortCodeAndDomain('abc123', 's.test'), ); - $expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "doma.in", since it ' + $expectedMessage = 'Impossible to delete short URL with short code "abc123" for domain "s.test", since it ' . 'has more than "10" visits.'; self::assertEquals([ 'shortCode' => 'abc123', - 'domain' => 'doma.in', + 'domain' => 's.test', 'threshold' => 10, ], $e->getAdditionalData()); self::assertEquals($expectedMessage, $e->getMessage()); diff --git a/module/Core/test/Exception/MalformedBodyExceptionTest.php b/module/Core/test/Exception/MalformedBodyExceptionTest.php new file mode 100644 index 00000000..ecccfdf2 --- /dev/null +++ b/module/Core/test/Exception/MalformedBodyExceptionTest.php @@ -0,0 +1,27 @@ +getPrevious()); + self::assertEquals('Provided request does not contain a valid JSON body.', $e->getMessage()); + self::assertEquals('Provided request does not contain a valid JSON body.', $e->getDetail()); + self::assertEquals('Malformed request body', $e->getTitle()); + self::assertEquals('https://shlink.io/api/error/malformed-request-body', $e->getType()); + self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); + } +} diff --git a/module/Core/test/Functions/FunctionsTest.php b/module/Core/test/Functions/FunctionsTest.php new file mode 100644 index 00000000..ad45812f --- /dev/null +++ b/module/Core/test/Functions/FunctionsTest.php @@ -0,0 +1,45 @@ + $enum + * @test + * @dataProvider provideEnums + */ + public function enumValuesReturnsExpectedValueForEnum(string $enum, array $expectedValues): void + { + self::assertEquals($expectedValues, enumValues($enum)); + } + + public function provideEnums(): iterable + { + yield EnvVars::class => [EnvVars::class, map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value)]; + yield VisitType::class => [ + VisitType::class, + map(VisitType::cases(), static fn (VisitType $envVar) => $envVar->value), + ]; + yield DeviceType::class => [ + DeviceType::class, + map(DeviceType::cases(), static fn (DeviceType $envVar) => $envVar->value), + ]; + yield OrderableField::class => [ + OrderableField::class, + map(OrderableField::cases(), static fn (OrderableField $envVar) => $envVar->value), + ]; + } +} diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index c480e11a..f1b2f3bb 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -210,7 +210,7 @@ class ImportedLinksProcessorTest extends TestCase ]), 'Skipped. Imported 4 visits', 4, - ShortUrl::createEmpty(), + ShortUrl::createFake(), ]; yield 'existing short URL with previous imported visits' => [ $createImportedUrl([ @@ -222,8 +222,8 @@ class ImportedLinksProcessorTest extends TestCase ]), 'Skipped. Imported 2 visits', 2, - ShortUrl::createEmpty()->setVisits(new ArrayCollection([ - Visit::fromImport(ShortUrl::createEmpty(), new ImportedShlinkVisit('', '', $now, null)), + ShortUrl::createFake()->setVisits(new ArrayCollection([ + Visit::fromImport(ShortUrl::createFake(), new ImportedShlinkVisit('', '', $now, null)), ])), ]; } diff --git a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php index be036264..3173e2ee 100644 --- a/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/DeleteShortUrlServiceTest.php @@ -29,8 +29,8 @@ class DeleteShortUrlServiceTest extends TestCase protected function setUp(): void { - $shortUrl = ShortUrl::createEmpty()->setVisits(new ArrayCollection( - map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())), + $shortUrl = ShortUrl::createFake()->setVisits(new ArrayCollection( + map(range(0, 10), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())), )); $this->shortCode = $shortUrl->getShortCode(); diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 026778ae..b69b369a 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -7,15 +7,21 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Sources\ImportSource; +use function Functional\every; use function Functional\map; use function range; use function strlen; +use function strtolower; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; @@ -32,17 +38,17 @@ class ShortUrlTest extends TestCase $this->expectException(ShortCodeCannotBeRegeneratedException::class); $this->expectExceptionMessage($expectedMessage); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); } public function provideInvalidShortUrls(): iterable { yield 'with custom slug' => [ - ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => ''])), + ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'custom-slug', 'longUrl' => 'longUrl'])), 'The short code cannot be regenerated on ShortUrls where a custom slug was provided.', ]; yield 'already persisted' => [ - ShortUrl::createEmpty()->setId('1'), + ShortUrl::createFake()->setId('1'), 'The short code can be regenerated only on new ShortUrls which have not been persisted yet.', ]; } @@ -56,7 +62,7 @@ class ShortUrlTest extends TestCase ): void { $firstShortCode = $shortUrl->getShortCode(); - $shortUrl->regenerateShortCode(); + $shortUrl->regenerateShortCode(ShortUrlMode::STRICT); $secondShortCode = $shortUrl->getShortCode(); self::assertNotEquals($firstShortCode, $secondShortCode); @@ -64,9 +70,9 @@ class ShortUrlTest extends TestCase public function provideValidShortUrls(): iterable { - yield 'no custom slug' => [ShortUrl::createEmpty()]; + yield 'no custom slug' => [ShortUrl::createFake()]; yield 'imported with custom slug' => [ShortUrl::fromImport( - new ImportedShlinkUrl(ImportSource::BITLY, '', [], Chronos::now(), null, 'custom-slug', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'longUrl', [], Chronos::now(), null, 'custom-slug', null), true, )]; } @@ -78,7 +84,7 @@ class ShortUrlTest extends TestCase public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( - [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => ''], + [ShortUrlInputFilter::SHORT_CODE_LENGTH => $length, 'longUrl' => 'longUrl'], )); self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); @@ -89,4 +95,64 @@ class ShortUrlTest extends TestCase yield [null, DEFAULT_SHORT_CODES_LENGTH]; yield from map(range(4, 10), fn (int $value) => [$value, $value]); } + + /** @test */ + public function deviceLongUrlsAreUpdated(): void + { + $shortUrl = ShortUrl::withLongUrl('foo'); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => 'android', + DeviceType::IOS->value => 'ios', + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => 'android', + DeviceType::IOS->value => 'ios', + DeviceType::DESKTOP->value => null, + ], $shortUrl->deviceLongUrls()); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => null, + DeviceType::DESKTOP->value => 'desktop', + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => 'ios', + DeviceType::DESKTOP->value => 'desktop', + ], $shortUrl->deviceLongUrls()); + + $shortUrl->update(ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + ], + ])); + self::assertEquals([ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + DeviceType::DESKTOP->value => 'desktop', + ], $shortUrl->deviceLongUrls()); + } + + /** @test */ + public function generatesLowercaseOnlyShortCodesInLooselyMode(): void + { + $range = range(1, 1000); // Use a "big" number to reduce false negatives + $allFor = static fn (ShortUrlMode $mode): bool => every($range, static function () use ($mode): bool { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData( + [ShortUrlInputFilter::LONG_URL => 'foo'], + new UrlShortenerOptions(mode: $mode), + )); + $shortCode = $shortUrl->getShortCode(); + + return $shortCode === strtolower($shortCode); + }); + + self::assertTrue($allFor(ShortUrlMode::LOOSELY)); + self::assertFalse($allFor(ShortUrlMode::STRICT)); + } } diff --git a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php index cc18be07..ae0d9363 100644 --- a/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortCodeUniquenessHelperTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Domain\Entity\Domain; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; @@ -22,7 +23,7 @@ class ShortCodeUniquenessHelperTest extends TestCase protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->helper = new ShortCodeUniquenessHelper($this->em); + $this->helper = new ShortCodeUniquenessHelper($this->em, new UrlShortenerOptions()); $this->shortUrl = $this->createMock(ShortUrl::class); $this->shortUrl->method('getShortCode')->willReturn('abc123'); @@ -57,7 +58,7 @@ class ShortCodeUniquenessHelperTest extends TestCase public function provideDomains(): iterable { yield 'no domain' => [null, null]; - yield 'domain' => [Domain::withAuthority($authority = 'doma.in'), $authority]; + yield 'domain' => [Domain::withAuthority($authority = 's.test'), $authority]; } /** @test */ diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index cb94a9f1..341ff6bf 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -4,12 +4,19 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; +use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ServerRequestInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; +use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; +use const ShlinkioTest\Shlink\IOS_USER_AGENT; + class ShortUrlRedirectionBuilderTest extends TestCase { private ShortUrlRedirectionBuilder $redirectionBuilder; @@ -26,74 +33,92 @@ class ShortUrlRedirectionBuilderTest extends TestCase */ public function buildShortUrlRedirectBuildsExpectedUrl( string $expectedUrl, - array $query, + ServerRequestInterface $request, ?string $extraPath, ?bool $forwardQuery, ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, + 'deviceLongUrls' => [ + DeviceType::ANDROID->value => 'https://domain.com/android', + DeviceType::IOS->value => 'https://domain.com/ios', + ], ])); - $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); self::assertEquals($expectedUrl, $result); } public function provideData(): iterable { - yield ['https://domain.com/foo/bar?some=thing', [], null, true]; - yield ['https://domain.com/foo/bar?some=thing', [], null, null]; - yield ['https://domain.com/foo/bar?some=thing', [], null, false]; - yield ['https://domain.com/foo/bar?some=thing&else', ['else' => null], null, true]; - yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar'], null, null]; - yield ['https://domain.com/foo/bar?some=thing', ['foo' => 'bar'], null, false]; - yield ['https://domain.com/foo/bar?some=thing&123=foo', ['123' => 'foo'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, true]; - yield ['https://domain.com/foo/bar?some=thing&456=foo', [456 => 'foo'], null, null]; - yield ['https://domain.com/foo/bar?some=thing', [456 => 'foo'], null, false]; + $request = static fn (array $query = []) => ServerRequestFactory::fromGlobals()->withQueryParams($query); + + yield ['https://domain.com/foo/bar?some=thing', $request(), null, true]; + yield ['https://domain.com/foo/bar?some=thing', $request(), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request(), null, false]; + yield ['https://domain.com/foo/bar?some=thing&else', $request(['else' => null]), null, true]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&foo=bar', $request(['foo' => 'bar']), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request(['foo' => 'bar']), null, false]; + yield ['https://domain.com/foo/bar?some=thing&123=foo', $request(['123' => 'foo']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, true]; + yield ['https://domain.com/foo/bar?some=thing&456=foo', $request([456 => 'foo']), null, null]; + yield ['https://domain.com/foo/bar?some=thing', $request([456 => 'foo']), null, false]; yield [ 'https://domain.com/foo/bar?some=overwritten&foo=bar', - ['foo' => 'bar', 'some' => 'overwritten'], + $request(['foo' => 'bar', 'some' => 'overwritten']), null, true, ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'), null, true, ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, null, ]; yield [ 'https://domain.com/foo/bar?some=thing', - ['foobar' => 'notrack', 'some' => 'overwritten'], + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, false, ]; - yield ['https://domain.com/foo/bar/something/else-baz?some=thing', [], '/something/else-baz', true]; + yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - ['hello' => 'world'], + $request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT), '/something/else-baz', true, ]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - ['hello' => 'world'], + $request(['hello' => 'world']), '/something/else-baz', null, ]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing', - ['hello' => 'world'], + $request(['hello' => 'world']), '/something/else-baz', false, ]; + yield [ + 'https://domain.com/android/something', + $request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT), + '/something', + false, + ]; + yield [ + 'https://domain.com/ios?foo=bar', + $request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT), + null, + null, + ]; } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index b6d5a123..fc8c7579 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -30,7 +30,7 @@ class ShortUrlStringifierTest extends TestCase { $shortUrlWithShortCode = fn (string $shortCode, ?string $domain = null) => ShortUrl::create( ShortUrlCreation::fromRawData([ - 'longUrl' => '', + 'longUrl' => 'longUrl', 'customSlug' => $shortCode, 'domain' => $domain, ]), diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index 355bec0e..c157403e 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -141,8 +141,8 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $type->method('isRegularNotFound')->willReturn(true); $type->method('isInvalidShortUrl')->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type) - ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); - $shortUrl = ShortUrl::withLongUrl(''); + ->withUri(new Uri('https://s.test/shortCode/bar/baz')); + $shortUrl = ShortUrl::withLongUrl('longUrl'); $currentIteration = 1; $this->resolver->expects($this->exactly($expectedResolveCalls))->method('resolveEnabledShortUrl')->with( @@ -159,7 +159,7 @@ class ExtraPathRedirectMiddlewareTest extends TestCase ); $this->redirectionBuilder->expects($this->once())->method('buildShortUrlRedirect')->with( $shortUrl, - [], + $this->isInstanceOf(ServerRequestInterface::class), $expectedExtraPath, )->willReturn('the_built_long_url'); $this->redirectResponseHelper->expects($this->once())->method('buildRedirectResponse')->with( diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 51457264..9582180b 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -6,9 +6,11 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use stdClass; @@ -69,6 +71,40 @@ class ShortUrlCreationTest extends TestCase yield [[ ShortUrlInputFilter::LONG_URL => [], ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => null, + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + 'invalid' => 'https://shlink.io', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::DESKTOP->value => '', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::DESKTOP->value => null, + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::IOS->value => ' ', + ], + ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', + ShortUrlInputFilter::DEVICE_LONG_URLS => [ + DeviceType::IOS->value => 'bar', + DeviceType::ANDROID->value => [], + ], + ]]; } /** @@ -79,41 +115,45 @@ class ShortUrlCreationTest extends TestCase string $customSlug, string $expectedSlug, bool $multiSegmentEnabled = false, + ShortUrlMode $shortUrlMode = ShortUrlMode::STRICT, ): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, - 'longUrl' => '', - EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, - ]); + 'longUrl' => 'longUrl', + ], new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentEnabled, mode: $shortUrlMode)); - self::assertTrue($meta->hasValidSince()); - self::assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); + self::assertTrue($creation->hasValidSince()); + self::assertEquals(Chronos::parse('2015-01-01'), $creation->validSince); - self::assertFalse($meta->hasValidUntil()); - self::assertNull($meta->getValidUntil()); + self::assertFalse($creation->hasValidUntil()); + self::assertNull($creation->validUntil); - self::assertTrue($meta->hasCustomSlug()); - self::assertEquals($expectedSlug, $meta->getCustomSlug()); + self::assertTrue($creation->hasCustomSlug()); + self::assertEquals($expectedSlug, $creation->customSlug); - self::assertFalse($meta->hasMaxVisits()); - self::assertNull($meta->getMaxVisits()); + self::assertFalse($creation->hasMaxVisits()); + self::assertNull($creation->maxVisits); } public function provideCustomSlugs(): iterable { yield ['🔥', '🔥']; yield ['🦣 🍅', '🦣-🍅']; + yield ['🦣 🍅', '🦣-🍅', false, ShortUrlMode::LOOSELY]; yield ['foobar', 'foobar']; yield ['foo bar', 'foo-bar']; yield ['foo bar baz', 'foo-bar-baz']; yield ['foo bar-baz', 'foo-bar-baz']; + yield ['foo BAR-baz', 'foo-bar-baz', false, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo/bar/baz', true]; yield ['/foo/bar/baz', 'foo/bar/baz', true]; + yield ['/foo/baR/baZ', 'foo/bar/baz', true, ShortUrlMode::LOOSELY]; yield ['foo/bar/baz', 'foo-bar-baz']; yield ['/foo/bar/baz', '-foo-bar-baz']; yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; + yield ['UPPER_lower', 'upper_lower', false, ShortUrlMode::LOOSELY]; yield ['more~url_special.chars', 'more~url_special.chars']; yield ['구글', '구글']; yield ['グーグル', 'グーグル']; @@ -127,12 +167,12 @@ class ShortUrlCreationTest extends TestCase */ public function titleIsCroppedIfTooLong(?string $title, ?string $expectedTitle): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'title' => $title, - 'longUrl' => '', + 'longUrl' => 'longUrl', ]); - self::assertEquals($expectedTitle, $meta->getTitle()); + self::assertEquals($expectedTitle, $creation->title); } public function provideTitles(): iterable @@ -153,12 +193,12 @@ class ShortUrlCreationTest extends TestCase */ public function emptyDomainIsDiscarded(?string $domain, ?string $expectedDomain): void { - $meta = ShortUrlCreation::fromRawData([ + $creation = ShortUrlCreation::fromRawData([ 'domain' => $domain, - 'longUrl' => '', + 'longUrl' => 'longUrl', ]); - self::assertSame($expectedDomain, $meta->getDomain()); + self::assertSame($expectedDomain, $creation->domain); } public function provideDomains(): iterable @@ -166,6 +206,6 @@ class ShortUrlCreationTest extends TestCase yield 'null domain' => [null, null]; yield 'empty domain' => ['', null]; yield 'trimmable domain' => [' ', null]; - yield 'valid domain' => ['doma.in', 'doma.in']; + yield 'valid domain' => ['s.test', 's.test']; } } diff --git a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php new file mode 100644 index 00000000..e03bb1ac --- /dev/null +++ b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php @@ -0,0 +1,54 @@ + $deviceLongUrls]); + + self::assertEquals($expectedDeviceLongUrls, $edition->deviceLongUrls); + self::assertEquals($expectedDevicesToRemove, $edition->devicesToRemove); + } + + public function provideDeviceLongUrls(): iterable + { + yield 'null' => [null, [], []]; + yield 'empty' => [[], [], []]; + yield 'only new urls' => [[ + DeviceType::DESKTOP->value => 'foo', + DeviceType::IOS->value => 'bar', + ], [ + DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'foo'), + DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'bar'), + ], []]; + yield 'only urls to remove' => [[ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + ], [], [DeviceType::ANDROID, DeviceType::IOS]]; + yield 'both' => [[ + DeviceType::DESKTOP->value => 'bar', + DeviceType::IOS->value => 'foo', + DeviceType::ANDROID->value => null, + ], [ + DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::DESKTOP->value, 'bar'), + DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'foo'), + ], [DeviceType::ANDROID]]; + } +} diff --git a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php new file mode 100644 index 00000000..8bac2f98 --- /dev/null +++ b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php @@ -0,0 +1,71 @@ +validator = new DeviceLongUrlsValidator(new NotEmpty()); + } + + /** + * @test + * @dataProvider provideNonArrayValues + */ + public function nonArrayValuesAreNotValid(mixed $invalidValue): void + { + self::assertFalse($this->validator->isValid($invalidValue)); + self::assertEquals(['NOT_ARRAY' => 'Provided value is not an array.'], $this->validator->getMessages()); + } + + public function provideNonArrayValues(): iterable + { + yield 'int' => [0]; + yield 'float' => [100.45]; + yield 'string' => ['foo']; + yield 'boolean' => [true]; + yield 'object' => [new stdClass()]; + yield 'null' => [null]; + } + + /** @test */ + public function unrecognizedKeysAreNotValid(): void + { + self::assertFalse($this->validator->isValid(['foo' => 'bar'])); + self::assertEquals( + ['INVALID_DEVICE' => 'You have provided at least one invalid device identifier.'], + $this->validator->getMessages(), + ); + } + + /** @test */ + public function everyUrlMustMatchLongUrlValidator(): void + { + self::assertFalse($this->validator->isValid([DeviceType::ANDROID->value => ''])); + self::assertEquals( + ['INVALID_LONG_URL' => 'At least one of the long URLs are invalid.'], + $this->validator->getMessages(), + ); + } + + /** @test */ + public function validValuesResultInValidResult(): void + { + self::assertTrue($this->validator->isValid([ + DeviceType::ANDROID->value => 'foo', + DeviceType::IOS->value => 'bar', + DeviceType::DESKTOP->value => 'baz', + ])); + } +} diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index 37a9f2e2..fedfd96f 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -52,12 +52,12 @@ class PersistenceShortUrlRelationResolverTest extends TestCase self::assertSame($result, $foundDomain); } self::assertInstanceOf(Domain::class, $result); - self::assertEquals($authority, $result->getAuthority()); + self::assertEquals($authority, $result->authority); } public function provideFoundDomains(): iterable { - $authority = 'doma.in'; + $authority = 's.test'; yield 'not found domain' => [null, $authority]; yield 'found domain' => [Domain::withAuthority($authority), $authority]; diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php index f0cc7023..443710bb 100644 --- a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -30,7 +30,7 @@ class SimpleShortUrlRelationResolverTest extends TestCase self::assertNull($result); } else { self::assertInstanceOf(Domain::class, $result); - self::assertEquals($domain, $result->getAuthority()); + self::assertEquals($domain, $result->authority); } } diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index be8eb852..446e95eb 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -36,10 +36,10 @@ class ShortUrlListServiceTest extends TestCase public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), - ShortUrl::createEmpty(), + ShortUrl::createFake(), + ShortUrl::createFake(), + ShortUrl::createFake(), + ShortUrl::createFake(), ]; $this->repo->expects($this->once())->method('findList')->willReturn($list); diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 9c2bcab3..9c42fefb 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -10,9 +10,11 @@ use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -35,7 +37,7 @@ class ShortUrlResolverTest extends TestCase { $this->em = $this->createMock(EntityManagerInterface::class); $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); - $this->urlResolver = new ShortUrlResolver($this->em); + $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } /** @@ -83,6 +85,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); @@ -101,6 +104,7 @@ class ShortUrlResolverTest extends TestCase $this->repo->expects($this->once())->method('findOneWithDomainFallback')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + ShortUrlMode::STRICT, )->willReturn($shortUrl); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($this->repo); @@ -114,7 +118,7 @@ class ShortUrlResolverTest extends TestCase $now = Chronos::now(); yield 'maxVisits reached' => [(function () { - $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => ''])); + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'longUrl'])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), fn () => Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()), @@ -123,16 +127,16 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => ''], + ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => 'longUrl'], ))]; yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => ''], + ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => 'longUrl'], ))]; yield 'mixed' => [(function () use ($now) { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => 3, 'validUntil' => $now->subMonth()->toAtomString(), - 'longUrl' => '', + 'longUrl' => 'longUrl', ])); $shortUrl->setVisits(new ArrayCollection(map( range(0, 4), diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 9cc0d955..7851aa9b 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -7,7 +7,9 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl; use Cake\Chronos\Chronos; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -18,26 +20,28 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use function array_fill_keys; +use function Shlinkio\Shlink\Core\enumValues; + class ShortUrlServiceTest extends TestCase { use ApiKeyHelpersTrait; private ShortUrlService $service; - private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlResolverInterface $urlResolver; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->em->method('persist')->willReturn(null); - $this->em->method('flush')->willReturn(null); + $em = $this->createMock(EntityManagerInterface::class); + $em->method('persist')->willReturn(null); + $em->method('flush')->willReturn(null); $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->titleResolutionHelper = $this->createMock(ShortUrlTitleResolutionHelperInterface::class); $this->service = new ShortUrlService( - $this->em, + $em, $this->urlResolver, $this->titleResolutionHelper, new SimpleShortUrlRelationResolver(), @@ -49,7 +53,7 @@ class ShortUrlServiceTest extends TestCase * @dataProvider provideShortUrlEdits */ public function updateShortUrlUpdatesProvidedData( - int $expectedValidateCalls, + InvocationOrder $expectedValidateCalls, ShortUrlEdition $shortUrlEdit, ?ApiKey $apiKey, ): void { @@ -61,7 +65,7 @@ class ShortUrlServiceTest extends TestCase $apiKey, )->willReturn($shortUrl); - $this->titleResolutionHelper->expects($this->exactly($expectedValidateCalls)) + $this->titleResolutionHelper->expects($expectedValidateCalls) ->method('processTitleAndValidateUrl') ->with($shortUrlEdit) ->willReturn($shortUrlEdit); @@ -72,34 +76,44 @@ class ShortUrlServiceTest extends TestCase $apiKey, ); + $resolveDeviceLongUrls = function () use ($shortUrlEdit): array { + $result = array_fill_keys(enumValues(DeviceType::class), null); + foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) { + $result[$longUrl->deviceType->value] = $longUrl->longUrl; + } + + return $result; + }; + self::assertSame($shortUrl, $result); - self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); - self::assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); - self::assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); - self::assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); + self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince()); + self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil()); + self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits()); + self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); + self::assertEquals($resolveDeviceLongUrls(), $shortUrl->deviceLongUrls()); } public function provideShortUrlEdits(): iterable { - yield 'no long URL' => [0, ShortUrlEdition::fromRawData( - [ - 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), - 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), - 'maxVisits' => 5, + yield 'no long URL' => [$this->never(), ShortUrlEdition::fromRawData([ + 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), + 'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(), + 'maxVisits' => 5, + ]), null]; + yield 'long URL and API key' => [$this->once(), ShortUrlEdition::fromRawData([ + 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), + 'maxVisits' => 10, + 'longUrl' => 'modifiedLongUrl', + ]), ApiKey::create()]; + yield 'long URL with validation' => [$this->once(), ShortUrlEdition::fromRawData([ + 'longUrl' => 'modifiedLongUrl', + 'validateUrl' => true, + ]), null]; + yield 'device redirects' => [$this->never(), ShortUrlEdition::fromRawData([ + 'deviceLongUrls' => [ + DeviceType::IOS->value => 'iosLongUrl', + DeviceType::ANDROID->value => 'androidLongUrl', ], - ), null]; - yield 'long URL' => [1, ShortUrlEdition::fromRawData( - [ - 'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(), - 'maxVisits' => 10, - 'longUrl' => 'modifiedLongUrl', - ], - ), ApiKey::create()]; - yield 'long URL with validation' => [1, ShortUrlEdition::fromRawData( - [ - 'longUrl' => 'modifiedLongUrl', - 'validateUrl' => true, - ], - ), null]; + ]), null]; } } diff --git a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php index c9df4e38..6159294b 100644 --- a/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/ShortUrl/Transformer/ShortUrlDataTransformerTest.php @@ -38,14 +38,14 @@ class ShortUrlDataTransformerTest extends TestCase $maxVisits = random_int(1, 1000); $now = Chronos::now(); - yield 'no metadata' => [ShortUrl::createEmpty(), [ + yield 'no metadata' => [ShortUrl::createFake(), [ 'validSince' => null, 'validUntil' => null, 'maxVisits' => null, ]]; yield 'max visits only' => [ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => $maxVisits, - 'longUrl' => '', + 'longUrl' => 'longUrl', ])), [ 'validSince' => null, 'validUntil' => null, @@ -53,7 +53,7 @@ class ShortUrlDataTransformerTest extends TestCase ]]; yield 'max visits and valid since' => [ ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => ''], + ['validSince' => $now, 'maxVisits' => $maxVisits, 'longUrl' => 'longUrl'], )), [ 'validSince' => $now->toAtomString(), @@ -63,7 +63,7 @@ class ShortUrlDataTransformerTest extends TestCase ]; yield 'both dates' => [ ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => ''], + ['validSince' => $now, 'validUntil' => $now->subDays(10), 'longUrl' => 'longUrl'], )), [ 'validSince' => $now->toAtomString(), @@ -72,9 +72,12 @@ class ShortUrlDataTransformerTest extends TestCase ], ]; yield 'everything' => [ - ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits, 'longUrl' => ''], - )), + ShortUrl::create(ShortUrlCreation::fromRawData([ + 'validSince' => $now, + 'validUntil' => $now->subDays(5), + 'maxVisits' => $maxVisits, + 'longUrl' => 'longUrl', + ])), [ 'validSince' => $now->toAtomString(), 'validUntil' => $now->subDays(5)->toAtomString(), diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php index fc2b89a2..0e7ec018 100644 --- a/module/Core/test/Util/RedirectResponseHelperTest.php +++ b/module/Core/test/Util/RedirectResponseHelperTest.php @@ -36,11 +36,15 @@ class RedirectResponseHelperTest extends TestCase public function provideRedirectConfigs(): iterable { yield 'status 302' => [302, 20, 302, null]; - yield 'status over 302' => [400, 20, 302, null]; + yield 'status 307' => [307, 20, 307, null]; + yield 'status over 308' => [400, 20, 302, null]; yield 'status below 301' => [201, 20, 302, null]; yield 'status 301 with valid expiration' => [301, 20, 301, 'private,max-age=20']; yield 'status 301 with zero expiration' => [301, 0, 301, 'private,max-age=30']; yield 'status 301 with negative expiration' => [301, -20, 301, 'private,max-age=30']; + yield 'status 308 with valid expiration' => [308, 20, 308, 'private,max-age=20']; + yield 'status 308 with zero expiration' => [308, 0, 308, 'private,max-age=30']; + yield 'status 308 with negative expiration' => [308, -20, 308, 'private,max-age=30']; } private function helper(?RedirectOptions $options = null): RedirectResponseHelper diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index 7024c946..5ae22005 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -18,7 +18,7 @@ class VisitTest extends TestCase */ public function isProperlyJsonSerialized(string $userAgent, bool $expectedToBePotentialBot): void { - $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); + $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor($userAgent, 'some site', '1.2.3.4', '')); self::assertEquals([ 'referer' => 'some site', @@ -48,7 +48,7 @@ class VisitTest extends TestCase public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void { $visit = Visit::forValidShortUrl( - ShortUrl::createEmpty(), + ShortUrl::createFake(), new Visitor('Chrome', 'some site', $address, ''), $anonymize, ); diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php index 059afcad..2f38a17a 100644 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -69,7 +69,7 @@ class OrphanVisitDataTransformerTest extends TestCase Visitor::fromRequest( ServerRequestFactory::fromGlobals()->withHeader('User-Agent', 'user-agent') ->withHeader('Referer', 'referer') - ->withUri(new Uri('https://doma.in/foo/bar')), + ->withUri(new Uri('https://s.test/foo/bar')), ), )->locate($location = VisitLocation::fromGeolocation(Location::emptyInstance())), [ @@ -78,7 +78,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'userAgent' => 'user-agent', 'visitLocation' => $location, 'potentialBot' => false, - 'visitedUrl' => 'https://doma.in/foo/bar', + 'visitedUrl' => 'https://s.test/foo/bar', 'type' => VisitType::REGULAR_404->value, ], ]; diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 8afd56db..0ed06e7d 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -53,11 +53,13 @@ class VisitsStatsHelperTest extends TestCase public function returnsExpectedVisitsStats(int $expectedCount): void { $repo = $this->createMock(VisitRepository::class); - $repo->expects($this->once())->method('countNonOrphanVisits')->with(new VisitsCountFiltering())->willReturn( - $expectedCount * 3, - ); - $repo->expects($this->once())->method('countOrphanVisits')->with( - $this->isInstanceOf(VisitsCountFiltering::class), + $repo->expects($this->exactly(2))->method('countNonOrphanVisits')->withConsecutive( + [new VisitsCountFiltering()], + [new VisitsCountFiltering(excludeBots: true)], + )->willReturn($expectedCount * 3); + $repo->expects($this->exactly(2))->method('countOrphanVisits')->withConsecutive( + [$this->isInstanceOf(VisitsCountFiltering::class)], + [$this->isInstanceOf(VisitsCountFiltering::class)], )->willReturn($expectedCount); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); @@ -84,7 +86,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(ShortUrlRepositoryInterface::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByShortCode')->with( $identifier, @@ -144,7 +146,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(TagRepository::class); $repo->expects($this->once())->method('tagExists')->with($tag, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByTag')->with($tag, $this->isInstanceOf(VisitsListFiltering::class))->willReturn( $list, @@ -185,7 +187,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('domainExists')->with($domain, $apiKey)->willReturn(true); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( $domain, @@ -215,7 +217,7 @@ class VisitsStatsHelperTest extends TestCase $repo = $this->createMock(DomainRepository::class); $repo->expects($this->never())->method('domainExists'); - $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo2 = $this->createMock(VisitRepository::class); $repo2->method('findVisitsByDomain')->with( 'DEFAULT', @@ -257,7 +259,7 @@ class VisitsStatsHelperTest extends TestCase /** @test */ public function nonOrphanVisitsAreReturnedAsExpected(): void { - $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance())); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countNonOrphanVisits')->with( $this->isInstanceOf(VisitsCountFiltering::class), diff --git a/module/Core/test/Visit/VisitsTrackerTest.php b/module/Core/test/Visit/VisitsTrackerTest.php index d981f755..9c27d5df 100644 --- a/module/Core/test/Visit/VisitsTrackerTest.php +++ b/module/Core/test/Visit/VisitsTrackerTest.php @@ -58,7 +58,7 @@ class VisitsTrackerTest extends TestCase public function provideTrackingMethodNames(): iterable { - yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; + yield 'track' => ['track', [ShortUrl::createFake(), Visitor::emptyInstance()]]; yield 'trackInvalidShortUrlVisit' => ['trackInvalidShortUrlVisit', [Visitor::emptyInstance()]]; yield 'trackBaseUrlVisit' => ['trackBaseUrlVisit', [Visitor::emptyInstance()]]; yield 'trackRegularNotFoundVisit' => ['trackRegularNotFoundVisit', [Visitor::emptyInstance()]]; diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index e60414b2..67509f1b 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; -use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -23,8 +22,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); - $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled; - return ShortUrlCreation::fromRawData($payload); + return ShortUrlCreation::fromRawData($payload, $this->urlShortenerOptions); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 89989dda..d7f5a360 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -25,6 +25,6 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction ShortUrlInputFilter::API_KEY => $apiKey, // This will usually be null, unless this API key enforces one specific domain ShortUrlInputFilter::DOMAIN => $request->getAttribute(ShortUrlInputFilter::DOMAIN), - ]); + ], $this->urlShortenerOptions); } } diff --git a/module/Rest/src/Action/Tag/TagsStatsAction.php b/module/Rest/src/Action/Tag/TagsStatsAction.php index cec8edd6..6db3c62a 100644 --- a/module/Rest/src/Action/Tag/TagsStatsAction.php +++ b/module/Rest/src/Action/Tag/TagsStatsAction.php @@ -20,7 +20,7 @@ class TagsStatsAction extends AbstractRestAction protected const ROUTE_PATH = '/tags/stats'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - public function __construct(private TagServiceInterface $tagService) + public function __construct(private readonly TagServiceInterface $tagService) { } diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 6132772a..403e6214 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -22,7 +22,7 @@ final class RoleDefinition { return new self( Role::DOMAIN_SPECIFIC, - ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], + ['domain_id' => $domain->getId(), 'authority' => $domain->authority], ); } } diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index beb9e0f9..57fecdd0 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -21,7 +21,7 @@ class ApiKey extends AbstractEntity private string $key; private ?Chronos $expirationDate = null; private bool $enabled; - /** @var Collection|ApiKeyRole[] */ + /** @var Collection */ private Collection $roles; private ?string $name = null; @@ -147,12 +147,9 @@ class ApiKey extends AbstractEntity $meta = $roleDefinition->meta; if ($this->hasRole($role)) { - /** @var ApiKeyRole $apiKeyRole */ - $apiKeyRole = $this->roles->get($role->value); - $apiKeyRole->updateMeta($meta); + $this->roles->get($role->value)?->updateMeta($meta); } else { - $apiKeyRole = new ApiKeyRole($roleDefinition->role, $roleDefinition->meta, $this); - $this->roles[$role->value] = $apiKeyRole; + $this->roles->set($role->value, new ApiKeyRole($role, $meta, $this)); } } } diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php index 8922de03..68fc1b38 100644 --- a/module/Rest/src/Middleware/BodyParserMiddleware.php +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Middleware; use Fig\Http\Message\RequestMethodInterface; +use JsonException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Exception\MalformedBodyException; use function Functional\contains; use function Shlinkio\Shlink\Common\json_decode; @@ -42,7 +44,11 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac return $request; } - $parsedJson = json_decode($rawBody); - return $request->withParsedBody($parsedJson); + try { + $parsedJson = json_decode($rawBody); + return $request->withParsedBody($parsedJson); + } catch (JsonException $e) { + throw MalformedBodyException::forInvalidJson($e); + } } } diff --git a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php index f4d01e97..8a88e340 100644 --- a/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php +++ b/module/Rest/src/Middleware/ShortUrl/OverrideDomainMiddleware.php @@ -34,11 +34,11 @@ class OverrideDomainMiddleware implements MiddlewareInterface if ($requestMethod === RequestMethodInterface::METHOD_POST) { /** @var array $payload */ $payload = $request->getParsedBody(); - $payload[ShortUrlInputFilter::DOMAIN] = $domain->getAuthority(); + $payload[ShortUrlInputFilter::DOMAIN] = $domain->authority; return $handler->handle($request->withParsedBody($payload)); } - return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->getAuthority())); + return $handler->handle($request->withAttribute(ShortUrlInputFilter::DOMAIN, $domain->authority)); } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 889b67af..0bb02c9e 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -118,7 +118,7 @@ class CreateShortUrlTest extends ApiTestCase public function provideMaxVisits(): array { - return map(range(10, 15), fn (int $i) => [$i]); + return map(range(10, 15), fn(int $i) => [$i]); } /** @test */ @@ -172,12 +172,14 @@ class CreateShortUrlTest extends ApiTestCase yield 'only long URL' => [['longUrl' => $longUrl]]; yield 'long URL and tags' => [['longUrl' => $longUrl, 'tags' => ['boo', 'far']]]; yield 'long URL and custom slug' => [['longUrl' => $longUrl, 'customSlug' => 'my cool slug']]; - yield 'several params' => [[ - 'longUrl' => $longUrl, - 'tags' => ['boo', 'far'], - 'validSince' => Chronos::now()->toAtomString(), - 'maxVisits' => 7, - ]]; + yield 'several params' => [ + [ + 'longUrl' => $longUrl, + 'tags' => ['boo', 'far'], + 'validSince' => Chronos::now()->toAtomString(), + 'maxVisits' => 7, + ], + ]; } /** @@ -261,21 +263,20 @@ class CreateShortUrlTest extends ApiTestCase public function provideInvalidUrls(): iterable { - yield 'empty URL' => ['', '2', 'INVALID_URL']; - yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; - yield 'API version 3' => ['', '3', 'https://shlink.io/api/error/invalid-url']; + yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; + yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url']; } /** * @test * @dataProvider provideInvalidArgumentApiVersions */ - public function failsToCreateShortUrlWithoutLongUrl(string $version, string $expectedType): void + public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void { $resp = $this->callApiWithKey( self::METHOD_POST, sprintf('/rest/v%s/short-urls', $version), - [RequestOptions::JSON => []], + [RequestOptions::JSON => $payload], ); $payload = $this->getJsonResponsePayload($resp); @@ -288,8 +289,22 @@ class CreateShortUrlTest extends ApiTestCase public function provideInvalidArgumentApiVersions(): iterable { - yield ['2', 'INVALID_ARGUMENT']; - yield ['3', 'https://shlink.io/api/error/invalid-data']; + yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT']; + yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data']; + yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT']; + yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data']; + yield 'empty device long url v2' => [[ + 'longUrl' => 'foo', + 'deviceLongUrls' => [ + 'android' => null, + ], + ], '2', 'INVALID_ARGUMENT']; + yield 'empty device long url v3' => [[ + 'longUrl' => 'foo', + 'deviceLongUrls' => [ + 'ios' => ' ', + ], + ], '3', 'https://shlink.io/api/error/invalid-data']; } /** @test */ @@ -297,7 +312,7 @@ class CreateShortUrlTest extends ApiTestCase { [$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ 'longUrl' => 'https://www.alejandrocelaya.com', - 'domain' => 'doma.in', + 'domain' => 's.test', ]); $getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode); $payload = $this->getJsonResponsePayload($getResp); @@ -359,7 +374,23 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_OK, $statusCode); self::assertEquals('🔥🔥🔥', $payload['title']); self::assertEquals('🦣🦣🦣', $payload['shortCode']); - self::assertEquals('http://doma.in/🦣🦣🦣', $payload['shortUrl']); + self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']); + } + + /** @test */ + public function canCreateShortUrlsWithDeviceLongUrls(): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', + 'deviceLongUrls' => [ + 'ios' => 'https://github.com/shlinkio/shlink/ios', + 'android' => 'https://github.com/shlinkio/shlink/android', + ], + ]); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('https://github.com/shlinkio/shlink/ios', $payload['deviceLongUrls']['ios'] ?? null); + self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null); } /** diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php index fdeec3b3..7abd4d5e 100644 --- a/module/Rest/test-api/Action/DomainRedirectsTest.php +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -61,7 +61,7 @@ class DomainRedirectsTest extends ApiTestCase 'invalidShortUrlRedirect' => null, ]]; yield 'default domain' => [[ - 'domain' => 'doma.in', + 'domain' => 's.test', 'regular404Redirect' => 'foo-for-default.com', ], [ 'baseUrlRedirect' => null, diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index fefbdcba..74fdebc5 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -154,7 +154,7 @@ class EditShortUrlTest extends ApiTestCase $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ 'maxVisits' => 100, ]]); - $editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url)); + $editedShortUrl = $this->getJsonResponsePayload($editResp); self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); self::assertEquals($domain, $editedShortUrl['domain']); @@ -170,4 +170,27 @@ class EditShortUrlTest extends ApiTestCase ]; yield 'no domain' => [null, 'https://shlink.io/documentation/']; } + + /** @test */ + public function deviceLongUrlsCanBeEdited(): void + { + $shortCode = 'def456'; + $url = new Uri(sprintf('/short-urls/%s', $shortCode)); + $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ + 'deviceLongUrls' => [ + 'android' => null, // This one will get removed + 'ios' => 'https://blog.alejandrocelaya.com/ios/edited', // This one will be edited + 'desktop' => 'https://blog.alejandrocelaya.com/desktop', // This one is new and will be created + ], + ]]); + $deviceLongUrls = $this->getJsonResponsePayload($editResp)['deviceLongUrls'] ?? []; + + self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); + self::assertArrayHasKey('ios', $deviceLongUrls); + self::assertEquals('https://blog.alejandrocelaya.com/ios/edited', $deviceLongUrls['ios']); + self::assertArrayHasKey('desktop', $deviceLongUrls); + self::assertEquals('https://blog.alejandrocelaya.com/desktop', $deviceLongUrls['desktop']); + self::assertArrayHasKey('android', $deviceLongUrls); + self::assertNull($deviceLongUrls['android']); + } } diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php index 54039c41..42f011c7 100644 --- a/module/Rest/test-api/Action/ListDomainsTest.php +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -34,7 +34,7 @@ class ListDomainsTest extends ApiTestCase { yield 'admin API key' => ['valid_api_key', [ [ - 'domain' => 'doma.in', + 'domain' => 's.test', 'isDefault' => true, 'redirects' => [ 'baseUrlRedirect' => null, @@ -72,7 +72,7 @@ class ListDomainsTest extends ApiTestCase ]]; yield 'author API key' => ['author_api_key', [ [ - 'domain' => 'doma.in', + 'domain' => 's.test', 'isDefault' => true, 'redirects' => [ 'baseUrlRedirect' => null, diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index d3a515c1..5cddfc50 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -6,6 +6,7 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -14,7 +15,7 @@ class ListShortUrlsTest extends ApiTestCase { private const SHORT_URL_SHLINK_WITH_TITLE = [ 'shortCode' => 'abc123', - 'shortUrl' => 'http://doma.in/abc123', + 'shortUrl' => 'http://s.test/abc123', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 3, @@ -36,7 +37,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_DOCS = [ 'shortCode' => 'ghi789', - 'shortUrl' => 'http://doma.in/ghi789', + 'shortUrl' => 'http://s.test/ghi789', 'longUrl' => 'https://shlink.io/documentation/', 'dateCreated' => '2018-05-01T00:00:00+00:00', 'visitsCount' => 2, @@ -80,7 +81,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_META = [ 'shortCode' => 'def456', - 'shortUrl' => 'http://doma.in/def456', + 'shortUrl' => 'http://s.test/def456', 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09' . '/acmailer-7-0-the-most-important-release-in-a-long-time/', @@ -104,7 +105,7 @@ class ListShortUrlsTest extends ApiTestCase ]; private const SHORT_URL_CUSTOM_SLUG = [ 'shortCode' => 'custom', - 'shortUrl' => 'http://doma.in/custom', + 'shortUrl' => 'http://s.test/custom', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2019-01-01T00:00:20+00:00', 'visitsCount' => 0, @@ -169,109 +170,124 @@ class ListShortUrlsTest extends ApiTestCase public function provideFilteredLists(): iterable { + // FIXME Cannot use enums in constants in PHP 8.1. Change this once support for PHP 8.1 is dropped + $withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [ + ...$shortUrl, + 'deviceLongUrls' => $longUrls ?? [ + DeviceType::ANDROID->value => null, + DeviceType::IOS->value => null, + DeviceType::DESKTOP->value => null, + ], + ]; + $shortUrlMeta = $withDeviceLongUrls(self::SHORT_URL_META, [ + DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', + DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', + DeviceType::DESKTOP->value => null, + ]); + yield [[], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['excludePastValidUntil' => 'true'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['excludeMaxVisitsReached' => 'true'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['orderBy' => 'title-DESC'], [ - self::SHORT_URL_META, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_DOCS, - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $withDeviceLongUrls(self::SHORT_URL_DOCS), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, ], 'valid_api_key']; yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, - self::SHORT_URL_SHLINK_WITH_TITLE, - self::SHORT_URL_DOCS, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + $withDeviceLongUrls(self::SHORT_URL_DOCS), ], 'valid_api_key']; yield [['tags' => ['foo']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['bar']], [ - self::SHORT_URL_META, + $shortUrlMeta, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ - self::SHORT_URL_META, + $shortUrlMeta, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz']], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key']; yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ - self::SHORT_URL_CUSTOM_DOMAIN, - self::SHORT_URL_META, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + $shortUrlMeta, ], 'valid_api_key']; yield [['searchTerm' => 'cool'], [ - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'valid_api_key']; yield [[], [ - self::SHORT_URL_CUSTOM_SLUG, - self::SHORT_URL_META, - self::SHORT_URL_SHLINK_WITH_TITLE, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), + $shortUrlMeta, + $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), ], 'author_api_key']; yield [[], [ - self::SHORT_URL_CUSTOM_DOMAIN, + $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), ], 'domain_api_key']; } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index a37193da..eee35d73 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class OrphanVisitsTest extends ApiTestCase { private const INVALID_SHORT_URL = [ - 'referer' => 'https://doma.in/foo', + 'referer' => 'https://s.test/foo', 'date' => '2020-03-01T00:00:00+00:00', 'userAgent' => 'cf-facebook', 'visitLocation' => null, @@ -20,7 +20,7 @@ class OrphanVisitsTest extends ApiTestCase 'type' => 'invalid_short_url', ]; private const REGULAR_NOT_FOUND = [ - 'referer' => 'https://doma.in/foo/bar', + 'referer' => 'https://s.test/foo/bar', 'date' => '2020-02-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, @@ -29,7 +29,7 @@ class OrphanVisitsTest extends ApiTestCase 'type' => 'regular_404', ]; private const BASE_URL = [ - 'referer' => 'https://doma.in', + 'referer' => 'https://s.test', 'date' => '2020-01-01T00:00:00+00:00', 'userAgent' => 'shlink-tests-agent', 'visitLocation' => null, diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php index 3b91cbf0..573f1c38 100644 --- a/module/Rest/test-api/Action/TagsStatsTest.php +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -52,16 +52,31 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'baz', 'shortUrlsCount' => 0, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], [ 'tag' => 'foo', 'shortUrlsCount' => 3, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 1, @@ -75,11 +90,21 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'baz', 'shortUrlsCount' => 0, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], ], [ 'currentPage' => 1, @@ -93,11 +118,21 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'bar', 'shortUrlsCount' => 1, 'visitsCount' => 2, + 'visitsSummary' => [ + 'total' => 2, + 'nonBots' => 1, + 'bots' => 1, + ], ], [ 'tag' => 'foo', 'shortUrlsCount' => 2, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 1, @@ -111,6 +146,11 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'foo', 'shortUrlsCount' => 2, 'visitsCount' => 5, + 'visitsSummary' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], ], ], [ 'currentPage' => 2, @@ -124,6 +164,11 @@ class TagsStatsTest extends ApiTestCase 'tag' => 'foo', 'shortUrlsCount' => 1, 'visitsCount' => 0, + 'visitsSummary' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], ], ], [ 'currentPage' => 1, diff --git a/module/Rest/test-api/Action/VisitStatsTest.php b/module/Rest/test-api/Action/VisitStatsTest.php new file mode 100644 index 00000000..424eba73 --- /dev/null +++ b/module/Rest/test-api/Action/VisitStatsTest.php @@ -0,0 +1,68 @@ +callApiWithKey(self::METHOD_GET, '/visits', apiKey: $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(['visits' => $expectedPayload], $payload); + } + + public function provideApiKeysAndResults(): iterable + { + yield 'valid API key' => ['valid_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 7, + 'nonBots' => 6, + 'bots' => 1, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 7, + 'orphanVisitsCount' => 3, + ]]; + yield 'domain-only API key' => ['domain_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 0, + 'nonBots' => 0, + 'bots' => 0, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 0, + 'orphanVisitsCount' => 3, + ]]; + yield 'author API key' => ['author_api_key', [ + 'nonOrphanVisits' => [ + 'total' => 5, + 'nonBots' => 4, + 'bots' => 1, + ], + 'orphanVisits' => [ + 'total' => 3, + 'nonBots' => 2, + 'bots' => 1, + ], + 'visitsCount' => 5, + 'orphanVisitsCount' => 3, + ]]; + } +} diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 9a876463..2d45a7bb 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -9,6 +9,7 @@ use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -48,6 +49,10 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', + 'deviceLongUrls' => [ + DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', + DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', + ], 'tags' => ['foo', 'bar'], ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); diff --git a/module/Rest/test-api/Fixtures/VisitsFixture.php b/module/Rest/test-api/Fixtures/VisitsFixture.php index ada0ebad..6076f95e 100644 --- a/module/Rest/test-api/Fixtures/VisitsFixture.php +++ b/module/Rest/test-api/Fixtures/VisitsFixture.php @@ -50,15 +50,15 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface ); $manager->persist($this->setVisitDate( - Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://doma.in', '1.2.3.4', '')), + Visit::forBasePath(new Visitor('shlink-tests-agent', 'https://s.test', '1.2.3.4', '')), '2020-01-01', )); $manager->persist($this->setVisitDate( - Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://doma.in/foo/bar', '1.2.3.4', '')), + Visit::forRegularNotFound(new Visitor('shlink-tests-agent', 'https://s.test/foo/bar', '1.2.3.4', '')), '2020-02-01', )); $manager->persist($this->setVisitDate( - Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://doma.in/foo', '1.2.3.4', 'foo.com')), + Visit::forInvalidShortUrl(new Visitor('cf-facebook', 'https://s.test/foo', '1.2.3.4', 'foo.com')), '2020-03-01', )); diff --git a/module/Rest/test-api/Middleware/BodyParserTest.php b/module/Rest/test-api/Middleware/BodyParserTest.php new file mode 100644 index 00000000..e2170c76 --- /dev/null +++ b/module/Rest/test-api/Middleware/BodyParserTest.php @@ -0,0 +1,27 @@ +callApiWithKey(self::METHOD_POST, '/short-urls', [ + RequestOptions::HEADERS => ['content-type' => 'application/json'], + RequestOptions::BODY => '{"foo', + ]); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(400, $resp->getStatusCode()); + self::assertEquals(400, $payload['status']); + self::assertEquals('Provided request does not contain a valid JSON body.', $payload['detail']); + self::assertEquals('Malformed request body', $payload['title']); + self::assertEquals('https://shlink.io/api/error/malformed-request-body', $payload['type']); + } +} diff --git a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php index 5ff409a0..df5fed43 100644 --- a/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php +++ b/module/Rest/test/Action/Domain/DomainRedirectsActionTest.php @@ -60,7 +60,7 @@ class DomainRedirectsActionTest extends TestCase array $redirects, array $expectedResult, ): void { - $authority = 'doma.in'; + $authority = 's.test'; $redirects['domain'] = $authority; $apiKey = ApiKey::create(); $request = ServerRequestFactory::fromGlobals()->withParsedBody($redirects) diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 246b2edf..15ce5389 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -37,7 +37,7 @@ class CreateShortUrlActionTest extends TestCase public function properShortcodeConversionReturnsData(): void { $apiKey = ApiKey::create(); - $shortUrl = ShortUrl::createEmpty(); + $shortUrl = ShortUrl::createFake(); $expectedMeta = $body = [ 'longUrl' => 'http://www.domain.com/foo/bar', 'validSince' => Chronos::now()->toAtomString(), diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index dde17ca6..ac788fa7 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -49,7 +49,7 @@ class EditShortUrlActionTest extends TestCase ->withParsedBody([ 'maxVisits' => 5, ]); - $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(ShortUrl::createEmpty()); + $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn(ShortUrl::createFake()); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 4164e78b..329c9717 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -29,7 +29,7 @@ class ListShortUrlsActionTest extends TestCase $this->action = new ListShortUrlsAction($this->service, new ShortUrlDataTransformer( new ShortUrlStringifier([ - 'hostname' => 'doma.in', + 'hostname' => 's.test', 'schema' => 'https', ]), )); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 14848696..42c185c9 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -43,7 +43,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase ])->withAttribute(ApiKey::class, $apiKey); $this->urlShortener->expects($this->once())->method('shorten')->with( ShortUrlCreation::fromRawData(['apiKey' => $apiKey, 'longUrl' => 'http://foobar.com']), - )->willReturn(ShortUrl::createEmpty()); + )->willReturn(ShortUrl::createFake()); $resp = $this->action->handle($request); diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index 3eb77dcb..62ca5aef 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -95,7 +95,7 @@ class AuthenticationMiddlewareTest extends TestCase { $baseRequest = fn (string $routeName) => ServerRequestFactory::fromGlobals()->withAttribute( RouteResult::class, - RouteResult::fromRoute(new Route($routeName, $this->getDummyMiddleware()), []), + RouteResult::fromRoute(new Route($routeName, $this->getDummyMiddleware())), // @phpstan-ignore-line ); $apiKeyMessage = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided'; $queryMessage = 'Expected authentication to be provided in "apiKey" query param'; diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index 63354a76..429a35ea 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -7,10 +7,12 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Exception\MalformedBodyException; use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware; class BodyParserMiddlewareTest extends TestCase @@ -65,7 +67,6 @@ class BodyParserMiddlewareTest extends TestCase /** @test */ public function jsonRequestsAreJsonDecoded(): void { - $test = $this; $body = new Stream('php://temp', 'wr'); $body->write('{"foo": "bar", "bar": ["one", 5]}'); $request = (new ServerRequest())->withMethod('PUT') @@ -73,16 +74,31 @@ class BodyParserMiddlewareTest extends TestCase $handler = $this->createMock(RequestHandlerInterface::class); $handler->expects($this->once())->method('handle')->with( $this->isInstanceOf(ServerRequestInterface::class), - )->willReturnCallback( - function (ServerRequestInterface $req) use ($test) { - $test->assertEquals([ - 'foo' => 'bar', - 'bar' => ['one', 5], - ], $req->getParsedBody()); + )->willReturnCallback(function (ServerRequestInterface $req) { + Assert::assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); - return new Response(); - }, - ); + return new Response(); + }); + + $this->middleware->process($request, $handler); + } + + /** @test */ + public function invalidBodyResultsInException(): void + { + $body = new Stream('php://temp', 'wr'); + $body->write('{"foo": "bar", "bar": ["one'); + $request = (new ServerRequest())->withMethod('PUT') + ->withBody($body); + + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); + + $this->expectException(MalformedBodyException::class); + $this->expectExceptionMessage('Provided request does not contain a valid JSON body.'); $this->middleware->process($request, $handler); } diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index 58a3f34d..adfaf43e 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -51,7 +51,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $this->requestHandler->expects($this->once())->method('handle')->with( $this->isInstanceOf(ServerRequestInterface::class), - )->willReturn(new JsonResponse(['shortUrl' => 'http://doma.in/foo'])); + )->willReturn(new JsonResponse(['shortUrl' => 'http://s.test/foo'])); $response = $this->middleware->process($request, $this->requestHandler); diff --git a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php index 1af34a48..cc3ff21c 100644 --- a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php @@ -21,7 +21,7 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase protected function setUp(): void { $this->next = $this->createMock(RequestHandlerInterface::class); - $this->middleware = new DropDefaultDomainFromRequestMiddleware('doma.in'); + $this->middleware = new DropDefaultDomainFromRequestMiddleware('s.test'); } /** @@ -47,8 +47,8 @@ class DropDefaultDomainFromRequestMiddlewareTest extends TestCase { yield [[], []]; yield [['foo' => 'bar'], ['foo' => 'bar']]; - yield [['foo' => 'bar', 'domain' => 'doma.in'], ['foo' => 'bar']]; + yield [['foo' => 'bar', 'domain' => 's.test'], ['foo' => 'bar']]; yield [['foo' => 'bar', 'domain' => 'not_default'], ['foo' => 'bar', 'domain' => 'not_default']]; - yield [['domain' => 'doma.in'], []]; + yield [['domain' => 's.test'], []]; } } diff --git a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php index ad558abf..f91e9818 100644 --- a/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/OverrideDomainMiddlewareTest.php @@ -88,9 +88,9 @@ class OverrideDomainMiddlewareTest extends TestCase [ShortUrlInputFilter::DOMAIN => 'baz.com'], ]; yield 'more body params' => [ - Domain::withAuthority('doma.in'), + Domain::withAuthority('s.test'), [ShortUrlInputFilter::DOMAIN => 'baz.com', 'something' => 'else', 'foo' => 123], - [ShortUrlInputFilter::DOMAIN => 'doma.in', 'something' => 'else', 'foo' => 123], + [ShortUrlInputFilter::DOMAIN => 's.test', 'something' => 'else', 'foo' => 123], ]; }