diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..78f981ab --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,30 @@ +name: Publish release + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Use PHP 7.4 + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' # Publish release with lowest supported PHP version + tools: composer + extensions: swoole-4.5.5 + - name: Generate release assets + run: ./build.sh ${GITHUB_REF#refs/tags/v} + - name: Publish release with assets + uses: docker://antonyurchenko/git-release:latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALLOW_TAG_PREFIX: "true" + ALLOW_EMPTY_CHANGELOG: "true" + with: + args: | + build/shlink_*_dist.zip diff --git a/.travis.yml b/.travis.yml index 29395d33..3bf55b55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,25 +26,13 @@ jobs: php: '7.4' env: - COMPOSER_FLAGS='' - # Deploy release only on smallest supported PHP version - before_deploy: - - rm -f ocular.phar - - ./build.sh ${TRAVIS_TAG#?} - deploy: - - provider: releases - api_key: - secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I= - file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip" - skip_cleanup: true - on: - tags: true before_install: - echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - phpenv config-rm xdebug.ini || return 0 - sudo ./data/infra/ci/install-ms-odbc.sh - docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria - - yes | pecl install pdo_sqlsrv swoole-4.5.2 + - yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov install: - composer self-update @@ -56,12 +44,13 @@ before_script: - export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile) script: - - bin/test/run-api-tests.sh --coverage-php build/coverage-api.cov && composer ci + - composer ci + - bin/test/run-api-tests.sh - if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi after_success: - rm -f build/clover.xml - wget https://phar.phpunit.de/phpcov-7.0.2.phar - - phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml + - php phpcov-7.0.2.phar merge build --clover build/clover.xml - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover build/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index a50a42ef..185a9ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## 2.3.0 - 2020-08-09 +## [2.4.0] - 2020-11-08 +### Added +* [#829](https://github.com/shlinkio/shlink/issues/829) Added support for QR codes in SVG format, by passing `?format=svg` to the QR code URL. +* [#820](https://github.com/shlinkio/shlink/issues/820) Added new option to force enabling or disabling URL validation on a per-URL basis. -#### Added + Currently, there's a global config that tells if long URLs should be validated (by ensuring they are publicly accessible and return a 2xx status). However, this is either always applied or never applied. + Now, it is possible to enforce validation or enforce disabling validation when a new short URL is created or edited: + + * On the `POST /short-url` and `PATCH /short-url/{shortCode}` endpoints, you can now pass `validateUrl: true/false` in order to enforce enabling or disabling validation, ignoring the global config. If the value is not provided, the global config is still normally applied. + * On the `short-url:generate` CLI command, you can pass `--validate-url` or `--no-validate-url` flags, in order to enforce enabling or disabling validation. If none of them is provided, the global config is still normally applied. + +* [#838](https://github.com/shlinkio/shlink/issues/838) Added new endpoint and CLI command to list existing domains. + + It returns both default domain and specific domains that were used for some short URLs. + + * REST endpoint: `GET /rest/v2/domains` + * CLI Command: `domain:list` + +* [#832](https://github.com/shlinkio/shlink/issues/832) Added support to customize the port in which the docker image listens by using the `PORT` env var or the `port` config option. + +* [#860](https://github.com/shlinkio/shlink/issues/860) Added support to import links from bit.ly. + + Run the command `short-urls:import bitly` and introduce requested information in order to import all your links. + + Other sources will be supported in future releases. + +### Changed +* [#836](https://github.com/shlinkio/shlink/issues/836) Added support for the `-` notation while determining how to order the short URLs list, as in `?orderBy=shortCode-DESC`. This effectively deprecates the array notation (`?orderBy[shortCode]=DESC`), that will be removed in Shlink 3.0.0 +* [#782](https://github.com/shlinkio/shlink/issues/782) Added code coverage to API tests. +* [#858](https://github.com/shlinkio/shlink/issues/858) Updated to latest infection version. Updated docker images to PHP 7.4.11 and swoole 4.5.5 +* [#887](https://github.com/shlinkio/shlink/pull/887) Started tracking the API key used to create short URLs, in order to allow restrictions in future releases. + +### Deprecated +* [#883](https://github.com/shlinkio/shlink/issues/883) Deprecated `POST /tags` endpoint and `tag:create` command, as tags are created automatically while creating short URLs. + +### Removed +* *Nothing* + +### Fixed +* [#837](https://github.com/shlinkio/shlink/issues/837) Drastically improved performance when creating a new shortUrl and providing `findIfExists = true`. +* [#878](https://github.com/shlinkio/shlink/issues/878) Added missing `gmp` extension to the official docker image. + + +## [2.3.0] - 2020-08-09 +### Added * [#746](https://github.com/shlinkio/shlink/issues/746) Allowed to configure the kind of redirect you want to use for your short URLs. You can either set: * `302` redirects: Default behavior. Visitors always hit the server. @@ -22,77 +64,59 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this It has one limitation, though. Because of the way the CLI tooling works, all rows in the table must be loaded in memory. If the amount of URLs is too high, the command may fail due to too much memory usage. -#### Changed - +### Changed * [#508](https://github.com/shlinkio/shlink/issues/508) Added mutation checks to database tests. * [#790](https://github.com/shlinkio/shlink/issues/790) Updated to doctrine/migrations v3. * [#798](https://github.com/shlinkio/shlink/issues/798) Updated to guzzlehttp/guzzle v7. * [#822](https://github.com/shlinkio/shlink/issues/822) Updated docker image to use PHP 7.4.9 with Alpine 3.12 and swoole 4.5.2. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 2.2.2 - 2020-06-08 - -#### Added - +## [2.2.2] - 2020-06-08 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#769](https://github.com/shlinkio/shlink/issues/769) Fixed custom slugs not allowing valid URL characters, like `.`, `_` or `~`. * [#781](https://github.com/shlinkio/shlink/issues/781) Fixed memory leak when loading visits for a tag which is used for big amounts of short URLs. -## 2.2.1 - 2020-05-11 - -#### Added - +## [2.2.1] - 2020-05-11 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#764](https://github.com/shlinkio/shlink/issues/764) Fixed error when trying to match an existing short URL which does not have `validSince` and/or `validUntil`, but you are providing either one of them for the new one. -## 2.2.0 - 2020-05-09 - -#### Added - +## [2.2.0] - 2020-05-09 +### Added * [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server. Thanks to that, Shlink will be able to publish events that can be consumed in real time. @@ -119,71 +143,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation. -#### Changed - +### Changed * [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets. * [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered. * [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql. * [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled. * [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired. * [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer. -## 2.1.4 - 2020-04-30 - -#### Added - +## [2.1.4] - 2020-04-30 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits. -## 2.1.3 - 2020-04-09 - -#### Added - +## [2.1.3] - 2020-04-09 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache. * [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header. * [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL. @@ -191,230 +199,177 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead. -## 2.1.2 - 2020-03-29 - -#### Added - +## [2.1.2] - 2020-03-29 +### Added * *Nothing* -#### Changed - +### Changed * [#696](https://github.com/shlinkio/shlink/issues/696) Updated to infection v0.16. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#700](https://github.com/shlinkio/shlink/issues/700) Fixed migration not working with postgres. * [#690](https://github.com/shlinkio/shlink/issues/690) Fixed tags being incorrectly sluggified when filtering short URL lists, making results not be the expected. -## 2.1.1 - 2020-03-28 - -#### Added - +## [2.1.1] - 2020-03-28 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#697](https://github.com/shlinkio/shlink/issues/697) Recovered `.htaccess` file that was unintentionally removed in v2.1.0, making Shlink unusable with Apache. -## 2.1.0 - 2020-03-28 - -#### Added - +## [2.1.0] - 2020-03-28 +### Added * [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server. * [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis. * [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries. * [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole. * [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint. -#### Changed - +### Changed * [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9. * [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`. * When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue. * When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed. * [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug. * [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way. -## 2.0.5 - 2020-02-09 - -#### Added - +## [2.0.5] - 2020-02-09 +### Added * [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains. -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users. * [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL. -## 2.0.4 - 2020-02-02 - -#### Added - +## [2.0.4] - 2020-02-02 +### Added * *Nothing* -#### Changed - +### Changed * [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted. * [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API. * [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code. * [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name. -## 2.0.3 - 2020-01-27 - -#### Added - +## [2.0.3] - 2020-01-27 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected. * [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset. * [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled. * [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once. -## 2.0.2 - 2020-01-12 - -#### Added - +## [2.0.2] - 2020-01-12 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#614](https://github.com/shlinkio/shlink/issues/614) Fixed `OPTIONS` requests including the `Origin` header not always returning an empty body with status 2xx. * [#615](https://github.com/shlinkio/shlink/issues/615) Fixed query args with no value being lost from the long URL when users are redirected. -## 2.0.1 - 2020-01-10 - -#### Added - +## [2.0.1] - 2020-01-10 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#607](https://github.com/shlinkio/shlink/issues/607) Added missing info on UPGRADE.md doc. * [#610](https://github.com/shlinkio/shlink/issues/610) Fixed use of hardcoded quotes on a database migration which makes it fail on postgres. * [#605](https://github.com/shlinkio/shlink/issues/605) Fixed crashes occurring when migrating from old Shlink versions with nullable DB columns that are assigned to non-nullable entity typed props. -## 2.0.0 - 2020-01-08 - -#### Added - +## [2.0.0] - 2020-01-08 +### Added * [#429](https://github.com/shlinkio/shlink/issues/429) Added support for PHP 7.4 * [#529](https://github.com/shlinkio/shlink/issues/529) Created an UPGRADING.md file explaining how to upgrade from v1.x to v2.x * [#594](https://github.com/shlinkio/shlink/issues/594) Updated external shlink packages, including installer v4.0, which adds the option to ask for the redis cluster config. -#### Changed - +### Changed * [#592](https://github.com/shlinkio/shlink/issues/592) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.1.0. * [#530](https://github.com/shlinkio/shlink/issues/530) Migrated project from deprecated `zendframework` components to the new `laminas` and `mezzio` ones. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#429](https://github.com/shlinkio/shlink/issues/429) Dropped support for PHP 7.2 and 7.3 * [#229](https://github.com/shlinkio/shlink/issues/229) Remove everything which was deprecated, including: @@ -424,38 +379,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this See [UPGRADE](UPGRADE.md) doc in order to get details on how to migrate to this version. -#### Fixed - +### Fixed * [#600](https://github.com/shlinkio/shlink/issues/600) Fixed health action so that it works with and without version in the path. -## 1.21.1 - 2020-01-02 - -#### Added - +## [1.21.1] - 2020-01-02 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#596](https://github.com/shlinkio/shlink/issues/596) Fixed error when trying to download GeoLite2 database due to changes on how to get the database files. -## 1.21.0 - 2019-12-29 - -#### Added - +## [1.21.0] - 2019-12-29 +### Added * [#118](https://github.com/shlinkio/shlink/issues/118) API errors now implement the [problem details](https://tools.ietf.org/html/rfc7807) standard. In order to make it backwards compatible, two things have been done: @@ -485,103 +431,79 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this > The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io). -#### Changed - +### Changed * [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php. * [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%. * [#557](https://github.com/shlinkio/shlink/issues/557) Added a few php.ini configs for development and production docker images. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#570](https://github.com/shlinkio/shlink/issues/570) Fixed shlink version generated for docker images when building from `develop` branch. -## 1.20.3 - 2019-12-23 - -#### Added - +## [1.20.3] - 2019-12-23 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#585](https://github.com/shlinkio/shlink/issues/585) Fixed `PHP Fatal error: Uncaught Error: Class 'Shlinkio\Shlink\LocalLockFactory' not found` happening when running some CLI commands. -## 1.20.2 - 2019-12-06 - -#### Added - +## [1.20.2] - 2019-12-06 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#561](https://github.com/shlinkio/shlink/issues/561) Fixed `db:migrate` command failing because yaml extension is not installed, which makes config file not to be readable. * [#562](https://github.com/shlinkio/shlink/issues/562) Fixed internal server error being returned when renaming a tag to another tag's name. Now a meaningful API error with status 409 is returned. * [#555](https://github.com/shlinkio/shlink/issues/555) Fixed internal server error being returned when invalid dates are provided for new short URLs. Now a 400 is returned, as intended. -## 1.20.1 - 2019-11-17 - -#### Added - +## [1.20.1] - 2019-11-17 +### Added * [#519](https://github.com/shlinkio/shlink/issues/519) Documented how to customize web workers and task workers for the docker image. -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#512](https://github.com/shlinkio/shlink/issues/512) Fixed query params not being properly forwarded from short URL to long one. * [#540](https://github.com/shlinkio/shlink/issues/540) Fixed errors thrown when creating short URLs if the original URL has an internationalized domain name and URL validation is enabled. * [#528](https://github.com/shlinkio/shlink/issues/528) Ensured `db:create` and `db:migrate` commands do not silently fail when run as part of `install` or `update`. * [#518](https://github.com/shlinkio/shlink/issues/518) Fixed service which updates Geolite db file to use a local lock instead of a shared one, since every shlink instance holds its own db instance. -## 1.20.0 - 2019-11-02 - -#### Added - +## [1.20.0] - 2019-11-02 +### Added * [#491](https://github.com/shlinkio/shlink/issues/491) Added improved short code generation logic. Now, short codes are truly random, which removes the guessability factor existing in previous versions. @@ -598,30 +520,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#497](https://github.com/shlinkio/shlink/issues/497) Officially added support for MariaDB. -#### Changed - +### Changed * [#458](https://github.com/shlinkio/shlink/issues/458) Updated coding styles to use [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) v2.0.0. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#507](https://github.com/shlinkio/shlink/issues/507) Fixed error with too long original URLs by increasing size to the maximum value (2048) based on [the standard](https://stackoverflow.com/a/417184). * [#502](https://github.com/shlinkio/shlink/issues/502) Fixed error when providing the port as part of the domain on short URLs. * [#509](https://github.com/shlinkio/shlink/issues/509) Fixed error when trying to generate a QR code for a short URL which uses a custom domain. * [#522](https://github.com/shlinkio/shlink/issues/522) Highly mitigated errors thrown when lots of short URLs are created concurrently including new and existing tags. -## 1.19.0 - 2019-10-05 - -#### Added - +## [1.19.0] - 2019-10-05 +### Added * [#482](https://github.com/shlinkio/shlink/issues/482) Added support to serve shlink under a sub path. The `router.base_path` config option can be defined now to set the base path from which shlink is served. @@ -648,53 +564,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * If the domain is not known but the short code/slug is defined for default domain, the user is redirected there and the visit is tracked. * In any other case, no redirection happens and no visit is tracked (if a fall back redirection is configured for not-found URLs, it will still happen). -#### Changed - +### Changed * [#486](https://github.com/shlinkio/shlink/issues/486) Updated to [shlink-installer](https://github.com/shlinkio/shlink-installer) v2, which supports asking for base path in which shlink is served. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#435](https://github.com/shlinkio/shlink/issues/435) Removed translations for error pages. All error pages are in english now. -#### Fixed - +### Fixed * *Nothing* -## 1.18.1 - 2019-08-24 - -#### Added - +## [1.18.1] - 2019-08-24 +### Added * *Nothing* -#### Changed - +### Changed * [#450](https://github.com/shlinkio/shlink/issues/450) Added PHP 7.4 to the build matrix, as an allowed-to-fail env. * [#441](https://github.com/shlinkio/shlink/issues/441) and [#443](https://github.com/shlinkio/shlink/issues/443) Split some logic into independent modules. * [#451](https://github.com/shlinkio/shlink/issues/451) Updated to infection 0.13. * [#467](https://github.com/shlinkio/shlink/issues/467) Moved docker image config to main Shlink repo. -#### Deprecated - +### Deprecated * [#428](https://github.com/shlinkio/shlink/issues/428) Deprecated preview-generation feature. It will keep working but it will be removed in Shlink v2.0.0 -#### Removed - +### Removed * [#468](https://github.com/shlinkio/shlink/issues/468) Removed APCu extension from docker image. -#### Fixed - +### Fixed * [#449](https://github.com/shlinkio/shlink/issues/449) Fixed error when trying to save too big referrers on PostgreSQL. -## 1.18.0 - 2019-08-08 - -#### Added - +## [1.18.0] - 2019-08-08 +### Added * [#411](https://github.com/shlinkio/shlink/issues/411) Added new `meta` property on the `ShortUrl` REST API model. These endpoints are affected and include the new property when suitable: @@ -736,31 +640,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#442](https://github.com/shlinkio/shlink/pull/442) Created `db:migrate` command, which improves doctrine's migrations command by generating a lock, preventing it to be run concurrently. -#### Changed - +### Changed * [#430](https://github.com/shlinkio/shlink/issues/430) Updated to [shlinkio/php-coding-standard](https://github.com/shlinkio/php-coding-standard) 1.2.2 * [#305](https://github.com/shlinkio/shlink/issues/305) Implemented changes which will allow Shlink to be truly clusterizable. * [#262](https://github.com/shlinkio/shlink/issues/262) Increased mutation score to 75%. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#416](https://github.com/shlinkio/shlink/issues/416) Fixed error thrown when trying to locate visits after the GeoLite2 DB is downloaded for the first time. * [#424](https://github.com/shlinkio/shlink/issues/424) Updated wkhtmltoimage to version 0.12.5 * [#427](https://github.com/shlinkio/shlink/issues/427) and [#434](https://github.com/shlinkio/shlink/issues/434) Fixed shlink being unusable after a database error on swoole contexts. -## 1.17.0 - 2019-05-13 - -#### Added - +## [1.17.0] - 2019-05-13 +### Added * [#377](https://github.com/shlinkio/shlink/issues/377) Updated `visit:locate` command (formerly `visit:process`) to automatically update the GeoLite2 database if it is too old or it does not exist. This simplifies processing visits in a container-based infrastructure, since a fresh container is capable of getting an updated version of the file by itself. @@ -769,99 +667,75 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#373](https://github.com/shlinkio/shlink/issues/373) Added support for a simplified config. Specially useful to use with the docker container. -#### Changed - +### Changed * [#56](https://github.com/shlinkio/shlink/issues/56) Simplified supported cache, requiring APCu always. -#### Deprecated - +### Deprecated * [#406](https://github.com/shlinkio/shlink/issues/406) Deprecated `PUT /short-urls/{shortCode}` REST endpoint in favor of `PATCH /short-urls/{shortCode}`. -#### Removed - +### Removed * [#385](https://github.com/shlinkio/shlink/issues/385) Dropped support for PHP 7.1 * [#379](https://github.com/shlinkio/shlink/issues/379) Removed copyright from error templates. -#### Fixed - +### Fixed * *Nothing* -## 1.16.3 - 2019-03-30 - -#### Added - +## [1.16.3] - 2019-03-30 +### Added * *Nothing* -#### Changed - +### Changed * [#153](https://github.com/shlinkio/shlink/issues/153) Updated to [doctrine/migrations](https://github.com/doctrine/migrations) version 2.0.0 * [#376](https://github.com/shlinkio/shlink/issues/376) Allowed `visit:update-db` command to not return an error exit code even if download fails, by passing the `-i` flag. * [#341](https://github.com/shlinkio/shlink/issues/341) Improved database tests so that they are executed against all supported database engines. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#382](https://github.com/shlinkio/shlink/issues/382) Fixed existing short URLs not properly checked when providing the `findIfExists` flag. -## 1.16.2 - 2019-03-05 - -#### Added - +## [1.16.2] - 2019-03-05 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#368](https://github.com/shlinkio/shlink/issues/368) Fixed error produced when running a `SELECT COUNT(...)` with `ORDER BY` in PostgreSQL databases. -## 1.16.1 - 2019-02-26 - -#### Added - +## [1.16.1] - 2019-02-26 +### Added * *Nothing* -#### Changed - +### Changed * [#363](https://github.com/shlinkio/shlink/issues/363) Updated to `shlinkio/php-coding-standard` version 1.1.0 -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#362](https://github.com/shlinkio/shlink/issues/362) Fixed all visits without an IP address being processed every time the `visit:process` command is executed. -## 1.16.0 - 2019-02-23 - -#### Added - +## [1.16.0] - 2019-02-23 +### Added * [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures. Call [GET /rest/health] in order to get a response like this: @@ -895,8 +769,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service. -#### Changed - +### Changed * [#342](https://github.com/shlinkio/shlink/issues/342) The installer no longer asks for a charset to be provided, and instead, it shuffles the base62 charset. * [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated. * [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build. @@ -904,51 +777,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool. * [#261](https://github.com/shlinkio/shlink/issues/261) Increased mutation score to 70%. -#### Deprecated - +### Deprecated * [#351](https://github.com/shlinkio/shlink/issues/351) Deprecated `config:generate-charset` and `config:generate-secret` CLI commands. -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#317](https://github.com/shlinkio/shlink/issues/317) Fixed error while trying to generate previews because of global config file being deleted by mistake by build script. * [#307](https://github.com/shlinkio/shlink/issues/307) Fixed memory leak while trying to process huge amounts of visits due to the query not being properly paginated. -## 1.15.1 - 2018-12-16 - -#### Added - +## [1.15.1] - 2018-12-16 +### Added * [#162](https://github.com/shlinkio/shlink/issues/162) Added non-rest endpoints to swagger definition. -#### Changed - +### Changed * [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image. * [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2. * [#321](https://github.com/shlinkio/shlink/issues/321) Extracted entities mappings from entities to external config files. * [#308](https://github.com/shlinkio/shlink/issues/308) Automated docker image building. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#301](https://github.com/shlinkio/shlink/issues/301) Removed custom `AccessLogFactory` in favor of the implementation included in [zendframework/zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) v2.2.0 -#### Fixed - +### Fixed * [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser. * [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything. -## 1.15.0 - 2018-12-02 - -#### Added - +## [1.15.0] - 2018-12-02 +### Added * [#208](https://github.com/shlinkio/shlink/issues/208) Added initial support to run shlink using [swoole](https://www.swoole.co.uk/), a non-blocking IO server which improves the performance of shlink from 4 to 10 times. Run shlink with `./vendor/bin/zend-expressive-swoole start` to start-up the service, which will be exposed in port `8080`. @@ -959,54 +821,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this In order to make it backwards compatible, it keeps returning all visits by default, but it now allows to provide the `page` and `itemsPerPage` query parameters in order to configure the number of items to get. -#### Changed - +### Changed * [#267](https://github.com/shlinkio/shlink/issues/267) API responses and the CLI interface is no longer translated and uses english always. Only not found error templates are still translated. * [#289](https://github.com/shlinkio/shlink/issues/289) Extracted coding standard rules to a separated package. * [#273](https://github.com/shlinkio/shlink/issues/273) Improved code coverage in repository classes. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#278](https://github.com/shlinkio/shlink/pull/278) Added missing `X-Api-Key` header to the list of valid cross domain headers. * [#295](https://github.com/shlinkio/shlink/pull/295) Fixed custom slugs so that they are case sensitive and do not try to lowercase provided values. -## 1.14.1 - 2018-11-17 - -#### Added - +## [1.14.1] - 2018-11-17 +### Added * *Nothing* -#### Changed - +### Changed * [#260](https://github.com/shlinkio/shlink/issues/260) Increased mutation score to 65%. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#271](https://github.com/shlinkio/shlink/issues/271) Fixed memory leak produced when processing high amounts of visits at the same time. * [#272](https://github.com/shlinkio/shlink/issues/272) Fixed errors produced when trying to process visits multiple times in parallel, by using a lock which ensures only one instance is run at a time. -## 1.14.0 - 2018-11-16 - -#### Added - +## [1.14.0] - 2018-11-16 +### Added * [#236](https://github.com/shlinkio/shlink/issues/236) Added option to define a redirection to a custom URL when a user hits an invalid short URL. It only affects URLs matched as "short URL" where the short code is invalid, not any 404 that happens in the app. For example, a request to the path `/foo/bar` will keep returning a 404. @@ -1019,8 +869,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Previous service is still used as a fallback in case GeoLite DB does not contain any IP address. -#### Changed - +### Changed * [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase. * [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monlog's `PsrLogMessageProcessor`. * [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported. @@ -1030,71 +879,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#256](https://github.com/shlinkio/shlink/issues/256) Updated to Infection v0.11. * [#202](https://github.com/shlinkio/shlink/issues/202) Added missing response examples to OpenAPI docs. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#223](https://github.com/shlinkio/shlink/issues/223) Fixed PHPStan errors produced with symfony/console 4.1.5 -## 1.13.2 - 2018-10-18 - -#### Added - +## [1.13.2] - 2018-10-18 +### Added * [#233](https://github.com/shlinkio/shlink/issues/233) Added PHP 7.3 to build matrix allowing its failure. -#### Changed - +### Changed * [#235](https://github.com/shlinkio/shlink/issues/235) Improved update instructions (thanks to [tivyhosting](https://github.com/tivyhosting)). -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#237](https://github.com/shlinkio/shlink/issues/233) Solved errors when trying to geo-locate `null` IP addresses. Also improved how visitor IP addresses are discovered, thanks to [akrabat/ip-address-middleware](https://github.com/akrabat/ip-address-middleware) package. -## 1.13.1 - 2018-10-16 - -#### Added - +## [1.13.1] - 2018-10-16 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#231](https://github.com/shlinkio/shlink/issues/197) Fixed error when processing visits. -## 1.13.0 - 2018-10-06 - -#### Added - +## [1.13.0] - 2018-10-06 +### Added * [#197](https://github.com/shlinkio/shlink/issues/197) Added [cakephp/chronos](https://book.cakephp.org/3.0/en/chronos.html) library for date manipulations. * [#214](https://github.com/shlinkio/shlink/issues/214) Improved build script, which allows builds to be done without "jumping" outside the project directory, and generates smaller dist files. @@ -1107,16 +939,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * Visits threshold to allow short URLs to be deleted. * Check the visits threshold when trying to delete a short URL via REST API. -#### Changed - +### Changed * [#211](https://github.com/shlinkio/shlink/issues/211) Extracted installer to its own module, which will simplify moving it to a separated package in the future. * [#200](https://github.com/shlinkio/shlink/issues/200) and [#201](https://github.com/shlinkio/shlink/issues/201) Renamed REST Action classes and CLI Command classes to use the concept of `ShortUrl` instead of the concept of `ShortCode` when referring to the entity, and left the `short code` concept to the identifier which is used as a unique code for a specific `Short URL`. * [#181](https://github.com/shlinkio/shlink/issues/181) When importing the configuration from a previous shlink installation, it no longer asks to import every block. Instead, it is capable of detecting only new config options introduced in the new version, and ask only for those. If no new options are found and you have selected to import config, no further questions will be asked and shlink will just import the old config. -#### Deprecated - +### Deprecated * [#205](https://github.com/shlinkio/shlink/issues/205) Deprecated `[POST /authenticate]` endpoint, and allowed any API request to be automatically authenticated using the `X-Api-Key` header with a valid API key. This effectively deprecates the `Authorization: Bearer ` authentication form, but it will keep working. @@ -1125,20 +955,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this In both cases, backwards compatibility has been retained and the old ones are aliases for the new ones, but the old ones are considered deprecated. -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#203](https://github.com/shlinkio/shlink/issues/203) Fixed some warnings thrown while unzipping distributable files. * [#206](https://github.com/shlinkio/shlink/issues/206) An error is now thrown during installation if any required param is left empty, making the installer display a message and ask again until a value is set. -## 1.12.0 - 2018-09-15 - -#### Added - +## [1.12.0] - 2018-09-15 +### Added * [#187](https://github.com/shlinkio/shlink/issues/187) Included an API endpoint and a CLI command to delete short URLs. Due to the implicit danger of this operation, the deletion includes a safety check. URLs cannot be deleted if they have more than a specific amount of visits. @@ -1167,31 +993,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#183](https://github.com/shlinkio/shlink/issues/183) and [#190](https://github.com/shlinkio/shlink/issues/190) Included important documentation improvements in the repository itself. You no longer need to go to the website in order to see how to install or use shlink. * [#186](https://github.com/shlinkio/shlink/issues/186) Added a small robots.txt file that prevents 404 errors to be logged due to search engines trying to index the domain where shlink is located. Thanks to [@robwent](https://github.com/robwent) for the contribution. -#### Changed - +### Changed * [#145](https://github.com/shlinkio/shlink/issues/145) Shlink now obfuscates IP addresses from visitors by replacing the latest octet by `0`, which does not affect geolocation and allows it to fulfil the GDPR. Other known services follow this same approach, like [Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en) or [Matomo](https://matomo.org/docs/privacy/#step-1-automatically-anonymize-visitor-ips) * [#182](https://github.com/shlinkio/shlink/issues/182) The short URL creation API endpoints now return the same model used for lists and details endpoints. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#188](https://github.com/shlinkio/shlink/issues/188) Shlink now allows multiple short URLs to be created that resolve to the same long URL. -## 1.11.0 - 2018-08-13 - -#### Added - +## [1.11.0] - 2018-08-13 +### Added * [#170](https://github.com/shlinkio/shlink/issues/170) and [#171](https://github.com/shlinkio/shlink/issues/171) Updated `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints to return more meaningful information and make their response consistent. The short URLs are now represented by this object in both cases: @@ -1212,43 +1032,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this The `originalUrl` property is considered deprecated and has been kept for backward compatibility purposes. It holds the same value as the `longUrl` property. -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * The `originalUrl` property in `[GET /short-codes]` and `[GET /short-codes/{shortCode}]` endpoints is now deprecated and replaced by the `longUrl` property. -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.10.2 - 2018-08-04 - -#### Added - +## [1.10.2] - 2018-08-04 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time. * [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations. @@ -1257,26 +1067,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses. -## 1.10.1 - 2018-08-02 - -#### Added - +## [1.10.1] - 2018-08-02 +### Added * *Nothing* -#### Changed - +### Changed * [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters. * [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint. * [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service. @@ -1287,34 +1091,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances. -## 1.10.0 - 2018-07-09 - -#### Added - +## [1.10.0] - 2018-07-09 +### Added * [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package -#### Changed - +### Changed * [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format * [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10 -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.9.1 - 2018-06-18 - -#### Added - +## [1.9.1] - 2018-06-18 +### Added * [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties. * Old structure: @@ -1342,235 +1138,180 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this } ``` -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm * [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary -## 1.9.0 - 2018-05-07 - -#### Added - +## [1.9.0] - 2018-05-07 +### Added * [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param. 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. -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions -## 1.8.1 - 2018-04-07 - -#### Added - +## [1.8.1] - 2018-04-07 +### Added * *Nothing* -#### Changed - +### Changed * [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed. -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script -## 1.8.0 - 2018-03-29 - -#### Added - +## [1.8.0] - 2018-03-29 +### 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. * [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests -#### Changed - +### Changed * [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3 * [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4 -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7 -#### Fixed - +### Fixed * *Nothing* -## 1.7.2 - 2018-03-26 - -#### Added - +## [1.7.2] - 2018-03-26 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2 -## 1.7.1 - 2018-03-21 - -#### Added - +## [1.7.1] - 2018-03-21 +### Added * *Nothing* -#### Changed - +### Changed * [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2 This will ease the upcoming update to expressive 3 -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2 -## 1.7.0 - 2018-01-21 - -#### Added - +## [1.7.0] - 2018-01-21 +### Added * [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param * [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes -#### Changed - +### Changed * [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle` * [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands * [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable` * [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs * [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv) -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.6.2 - 2017-10-25 - -#### Added - +## [1.6.2] - 2017-10-25 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration -## 1.6.1 - 2017-10-24 - -#### Added - +## [1.6.1] - 2017-10-24 +### Added * *Nothing* -#### Changed - +### Changed * [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.6.0 - 2017-10-23 - -#### Added - +## [1.6.0] - 2017-10-23 +### Added * [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code * [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range * [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL * [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code -#### Changed - +### Changed * [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit * [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception * [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match* @@ -1578,101 +1319,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates * [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5 -#### Fixed - +### Fixed * [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content -## 1.5.0 - 2017-07-16 - -#### Added - +## [1.5.0] - 2017-07-16 +### Added * [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI * [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST * [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating -#### Changed - +### Changed * [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions * [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs * [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware` -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format -## 1.4.0 - 2017-03-25 - -#### Added - +## [1.4.0] - 2017-03-25 +### Added * *Nothing* -#### Changed - +### Changed * [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2 -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.3.1 - 2017-01-22 - -#### Added - +## [1.3.1] - 2017-01-22 +### Added * *Nothing* -#### Changed - +### Changed * [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache * [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file * [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string * [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects * [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code * [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port -## 1.3.0 - 2016-10-23 - -#### Added - +## [1.3.0] - 2016-10-23 +### Added * [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list * [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody` * [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired @@ -1681,73 +1399,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file * [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes -#### Changed - +### Changed * [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* -## 1.2.2 - 2016-08-29 - -#### Added - +## [1.2.2] - 2016-08-29 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * Fixed minor bugs on CORS requests -## 1.2.1 - 2016-08-21 - -#### Added - +## [1.2.1] - 2016-08-21 +### Added * *Nothing* -#### Changed - +### Changed * *Nothing* -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API -## 1.2.0 - 2016-08-21 - -#### Added - +## [1.2.0] - 2016-08-21 +### Added * [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification * [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs * [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions @@ -1756,29 +1456,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars * [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database -#### Changed - +### Changed * [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment * [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability * [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments -## 1.1.0 - 2016-08-09 - -#### Added - +## [1.1.0] - 2016-08-09 +### 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` @@ -1787,38 +1481,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging * [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication -#### Changed - +### Changed * [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits * [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages * [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found * [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy -## 1.0.0 - 2016-08-01 - -#### Added - +## [1.0.0] - 2016-08-01 +### Added * [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one * [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals * [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range * [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps * [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage -#### Changed - +### Changed * [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header * [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table * [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request @@ -1829,39 +1516,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action" * [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink` -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail -#### Fixed - +### Fixed * [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL -## 0.2.0 - 2016-08-01 - -#### Added - +## [0.2.0] - 2016-08-01 +### Added * [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API * [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality * [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file -#### Changed - +### Changed * [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive -#### Deprecated - +### Deprecated * *Nothing* -#### Removed - +### Removed * *Nothing* -#### Fixed - +### Fixed * *Nothing* diff --git a/Dockerfile b/Dockerfile index 1cd764e0..04441788 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -FROM php:7.4.9-alpine3.12 as base +FROM php:7.4.11-alpine3.12 as base -ARG SHLINK_VERSION=2.2.2 +ARG SHLINK_VERSION=2.3.0 ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV SWOOLE_VERSION 4.5.2 +ENV SWOOLE_VERSION 4.5.5 ENV LC_ALL "C" WORKDIR /etc/shlink +# Install required PHP extensions RUN \ # Install mysql and calendar docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \ @@ -21,13 +22,16 @@ RUN \ docker-php-ext-install -j"$(nproc)" intl && \ # Install zip and gd apk add --no-cache libzip-dev zlib-dev libpng-dev && \ - docker-php-ext-install -j"$(nproc)" zip gd + docker-php-ext-install -j"$(nproc)" zip gd && \ + # Install gmp + apk add --no-cache gmp-dev && \ + docker-php-ext-install -j"$(nproc)" gmp # Install sqlsrv driver RUN if [ $(uname -m) == "x86_64" ]; then \ wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ - apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ + apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ pecl install pdo_sqlsrv && \ docker-php-ext-enable pdo_sqlsrv && \ apk del .phpize-deps && \ @@ -35,7 +39,7 @@ RUN if [ $(uname -m) == "x86_64" ]; then \ fi # Install swoole -RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \ +RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \ pecl install swoole-${SWOOLE_VERSION} && \ docker-php-ext-enable swoole && \ apk del .phpize-deps @@ -44,7 +48,7 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \ # Install shlink FROM base as builder COPY . . -COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar +COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \ php composer.phar clear-cache && \ @@ -59,7 +63,7 @@ LABEL maintainer="Alejandro Celaya " COPY --from=builder /etc/shlink . RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink -# Expose swoole port +# Expose default swoole port EXPOSE 8080 # Expose params config dir, since the user is expected to provide custom config from there diff --git a/README.md b/README.md index 9d51e389..1b03c048 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/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink) +[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.com/shlinkio/shlink) [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/) [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) @@ -64,7 +64,7 @@ In order to run Shlink, you will need a built version of the project. There are After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice. - > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching the generated dist file to it. + > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.com/shlinkio/shlink), attaching the generated dist file to it. ### Configure diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index fae0c628..f3236d1b 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -1,6 +1,7 @@ #!/usr/bin/env sh export APP_ENV=test export DB_DRIVER=mysql +export TEST_ENV=api # Try to stop server just in case it hanged in last execution vendor/bin/mezzio-swoole stop @@ -9,7 +10,7 @@ echo 'Starting server...' vendor/bin/mezzio-swoole start -d sleep 2 -phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* +vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* testsExitCode=$? vendor/bin/mezzio-swoole stop diff --git a/composer.json b/composer.json index f1a072fe..0fef97db 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^1.0", - "cakephp/chronos": "^1.2", + "cakephp/chronos": "^2.0", "cocur/slugify": "^4.0", "doctrine/cache": "^1.9", "doctrine/dbal": "^2.10", @@ -27,7 +27,6 @@ "guzzlehttp/guzzle": "^7.0", "laminas/laminas-config": "^3.3", "laminas/laminas-config-aggregator": "^1.1", - "laminas/laminas-dependency-plugin": "^1.0", "laminas/laminas-diactoros": "^2.1.3", "laminas/laminas-inputfilter": "^2.10", "laminas/laminas-paginator": "^2.8", @@ -50,9 +49,10 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^0.5", "ramsey/uuid": "^3.9", - "shlinkio/shlink-common": "^3.2.0", + "shlinkio/shlink-common": "^3.3.0", "shlinkio/shlink-config": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.4", + "shlinkio/shlink-importer": "^2.0.1", "shlinkio/shlink-installer": "^5.1.0", "shlinkio/shlink-ip-geolocation": "^1.5", "symfony/console": "^5.1", @@ -66,13 +66,15 @@ "devster/ubench": "^2.0", "dms/phpunit-arraysubset-asserts": "^0.2.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.16.1", - "phpstan/phpstan": "^0.12.18", - "phpunit/phpunit": "~9.0.1", + "infection/infection": "^0.20.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^0.12.52", + "phpunit/php-code-coverage": "^9.2", + "phpunit/phpunit": "^9.4", "roave/security-advisories": "dev-master", - "shlinkio/php-coding-standard": "~2.1.0", + "shlinkio/php-coding-standard": "~2.1.1", "shlinkio/shlink-test-utils": "^1.5", - "symfony/var-dumper": "^5.0" + "symfony/var-dumper": "^5.1" }, "autoload": { "psr-4": { @@ -93,7 +95,10 @@ "module/Core/test", "module/Core/test-db" ] - } + }, + "files": [ + "config/test/constants.php" + ] }, "scripts": { "ci": [ @@ -104,7 +109,7 @@ ], "cs": "phpcs", "cs:fix": "phpcbf", - "stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=6", + "stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6", "test": [ "@test:unit", "@test:db", @@ -114,8 +119,8 @@ "@test:unit:ci", "@test:db" ], - "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", - "test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", + "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox", + "test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", "test:db": [ "@test:db:sqlite:ci", "@test:db:mysql", @@ -123,14 +128,14 @@ "@test:db:postgres", "@test:db:ms" ], - "test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", + "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage", + "test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html", "infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered", "infect:ci:base": "@infect --skip-initial-tests", "infect:ci": [ @@ -166,6 +171,7 @@ "clean:dev": "Deletes artifacts which are gitignored and could affect dev env" }, "config": { - "sort-packages": true + "sort-packages": true, + "platform-check": false } } diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index 023b3c4e..dbc553f1 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use GuzzleHttp\Client; use Mezzio\Container; +use Psr\Http\Client\ClientInterface; return [ @@ -13,6 +15,10 @@ return [ ], ], + 'aliases' => [ + ClientInterface::class => Client::class, + ], + 'lazy_services' => [ 'proxies_target_dir' => 'data/proxies', 'proxies_namespace' => 'ShlinkProxy', diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 90137aa8..173c435c 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -5,7 +5,7 @@ declare(strict_types=1); return [ 'not_found_redirects' => [ - 'invalid_short_url' => null, // Formerly url_shortener.not_found_short_url.redirect_to + 'invalid_short_url' => null, 'regular_404' => null, 'base_url' => null, ], diff --git a/config/config.php b/config/config.php index 5ab429f0..ba0657fc 100644 --- a/config/config.php +++ b/config/config.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink; use Laminas\ConfigAggregator; +use Laminas\Diactoros; use Mezzio; use Mezzio\ProblemDetails; @@ -17,8 +18,10 @@ return (new ConfigAggregator\ConfigAggregator([ Mezzio\Plates\ConfigProvider::class, Mezzio\Swoole\ConfigProvider::class, ProblemDetails\ConfigProvider::class, + Diactoros\ConfigProvider::class, Common\ConfigProvider::class, Config\ConfigProvider::class, + Importer\ConfigProvider::class, IpGeolocation\ConfigProvider::class, EventDispatcher\ConfigProvider::class, Core\ConfigProvider::class, diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index 4cf01807..7bda8c10 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,12 +7,28 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; +use function register_shutdown_function; +use function sprintf; + +use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST; +use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT; + /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); $config = $container->get('config'); $em = $container->get(EntityManager::class); +$httpClient = $container->get('shlink_test_api_client'); + +// Start code coverage collecting on swoole process, and stop it when process shuts down +$httpClient->request('GET', sprintf('http://%s:%s/api-tests/start-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT)); +register_shutdown_function(function () use ($httpClient): void { + $httpClient->request( + 'GET', + sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT), + ); +}); $testHelper->createTestDb(); -ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client')); +ApiTest\ApiTestCase::setApiClient($httpClient); ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? [])); diff --git a/config/test/constants.php b/config/test/constants.php new file mode 100644 index 00000000..a2c880fc --- /dev/null +++ b/config/test/constants.php @@ -0,0 +1,8 @@ +includeDirectory($item); + } + $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); +} $buildDbConnection = function (): array { $driver = env('DB_DRIVER', 'sqlite'); @@ -78,8 +96,8 @@ return [ 'mezzio-swoole' => [ 'enable_coroutine' => false, 'swoole-http-server' => [ - 'host' => $swooleTestingHost, - 'port' => $swooleTestingPort, + 'host' => SWOOLE_TESTING_HOST, + 'port' => SWOOLE_TESTING_PORT, 'process-name' => 'shlink_test', 'options' => [ 'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid', @@ -88,6 +106,35 @@ return [ ], ], + 'routes' => !$isApiTest ? [] : [ + [ + 'name' => 'start_collecting_coverage', + 'path' => '/api-tests/start-coverage', + 'middleware' => middleware(static function () use (&$coverage) { + if ($coverage) { + $coverage->start('API tests'); + } + return new EmptyResponse(); + }), + 'allowed_methods' => ['GET'], + ], + [ + 'name' => 'dump_coverage', + 'path' => '/api-tests/stop-coverage', + 'middleware' => middleware(static function () use (&$coverage) { + if ($coverage) { + $basePath = __DIR__ . '/../../build/coverage-api'; + $coverage->stop(); + (new PHP())->process($coverage, $basePath . '.cov'); + (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); + } + + return new EmptyResponse(); + }), + 'allowed_methods' => ['GET'], + ], + ], + 'mercure' => [ 'public_hub_url' => null, 'internal_hub_url' => null, @@ -97,7 +144,7 @@ return [ 'dependencies' => [ 'services' => [ 'shlink_test_api_client' => new Client([ - 'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort), + 'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT), 'http_errors' => false, ]), ], diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 5b0ac28a..e419ba6b 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,9 +1,8 @@ -FROM php:7.4.9-alpine3.12 +FROM php:7.4.11-alpine3.12 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 ENV APCU_BC_VERSION 1.0.5 -ENV XDEBUG_VERSION 2.9.0 RUN apk update @@ -31,6 +30,9 @@ RUN docker-php-ext-install gd RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql +RUN apk add --no-cache gmp-dev +RUN docker-php-ext-install gmp + # Install APCu extension ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz RUN mkdir -p /usr/src/php/ext/apcu\ @@ -55,29 +57,17 @@ RUN rm /tmp/apcu_bc.tar.gz RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini -# Install xdebug -ADD https://pecl.php.net/get/xdebug-$XDEBUG_VERSION /tmp/xdebug.tar.gz -RUN mkdir -p /usr/src/php/ext/xdebug\ - && tar xf /tmp/xdebug.tar.gz -C /usr/src/php/ext/xdebug --strip-components=1 -# configure and install -RUN docker-php-ext-configure xdebug\ - && docker-php-ext-install xdebug -# cleanup -RUN rm /tmp/xdebug.tar.gz - -# Install sqlsrv driver +# Install pcov and sqlsrv driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install pdo_sqlsrv && \ - docker-php-ext-enable pdo_sqlsrv && \ + pecl install pdo_sqlsrv pcov && \ + docker-php-ext-enable pdo_sqlsrv pcov && \ apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk # Install composer -RUN php -r "readfile('https://getcomposer.org/installer');" | php -RUN chmod +x composer.phar -RUN mv composer.phar /usr/local/bin/composer +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer # Make home directory writable by anyone RUN chmod 777 /home diff --git a/data/infra/php.ini b/data/infra/php.ini index 5ef7b7ea..64838d11 100644 --- a/data/infra/php.ini +++ b/data/infra/php.ini @@ -4,3 +4,5 @@ memory_limit=-1 log_errors_max_len=0 zend.assertions=1 assert.exception=1 +pcov.enabled=1 +pcov.directory=module diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 70d52fa1..00d197ba 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:7.4.9-alpine3.12 +FROM php:7.4.11-alpine3.12 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.18 ENV APCU_BC_VERSION 1.0.5 ENV INOTIFY_VERSION 2.0.0 -ENV SWOOLE_VERSION 4.5.2 +ENV SWOOLE_VERSION 4.5.5 RUN apk update @@ -32,6 +32,9 @@ RUN docker-php-ext-install gd RUN apk add --no-cache postgresql-dev RUN docker-php-ext-install pdo_pgsql +RUN apk add --no-cache gmp-dev +RUN docker-php-ext-install gmp + # Install APCu extension ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz RUN mkdir -p /usr/src/php/ext/apcu\ @@ -66,19 +69,17 @@ RUN docker-php-ext-configure inotify\ # cleanup RUN rm /tmp/inotify.tar.gz -# Install swoole and mssql driver +# Install swoole, pcov and mssql driver RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \ - docker-php-ext-enable swoole pdo_sqlsrv && \ + pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \ + docker-php-ext-enable swoole pdo_sqlsrv pcov && \ apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk # Install composer -RUN php -r "readfile('https://getcomposer.org/installer');" | php -RUN chmod +x composer.phar -RUN mv composer.phar /usr/local/bin/composer +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer # Make home directory writable by anyone RUN chmod 777 /home diff --git a/data/migrations/Version20171021093246.php b/data/migrations/Version20171021093246.php index b66a2c3f..83f08e41 100644 --- a/data/migrations/Version20171021093246.php +++ b/data/migrations/Version20171021093246.php @@ -24,10 +24,10 @@ class Version20171021093246 extends AbstractMigration return; } - $shortUrls->addColumn('valid_since', Types::DATETIME, [ + $shortUrls->addColumn('valid_since', Types::DATETIME_MUTABLE, [ 'notnull' => false, ]); - $shortUrls->addColumn('valid_until', Types::DATETIME, [ + $shortUrls->addColumn('valid_until', Types::DATETIME_MUTABLE, [ 'notnull' => false, ]); } diff --git a/data/migrations/Version20201023090929.php b/data/migrations/Version20201023090929.php new file mode 100644 index 00000000..05d16c22 --- /dev/null +++ b/data/migrations/Version20201023090929.php @@ -0,0 +1,44 @@ +getTable('short_urls'); + $this->skipIf($shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); + + $shortUrls->addColumn(self::IMPORT_SOURCE_COLUMN, Types::STRING, [ + 'length' => 255, + 'notnull' => false, + ]); + $shortUrls->addColumn('import_original_short_code', Types::STRING, [ + 'length' => 255, + 'notnull' => false, + ]); + + $shortUrls->addUniqueIndex( + [self::IMPORT_SOURCE_COLUMN, 'import_original_short_code', 'domain_id'], + 'unique_imports', + ); + } + + public function down(Schema $schema): void + { + $shortUrls = $schema->getTable('short_urls'); + $this->skipIf(! $shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); + + $shortUrls->dropColumn(self::IMPORT_SOURCE_COLUMN); + $shortUrls->dropColumn('import_original_short_code'); + $shortUrls->dropIndex('unique_imports'); + } +} diff --git a/data/migrations/Version20201102113208.php b/data/migrations/Version20201102113208.php new file mode 100644 index 00000000..4b169532 --- /dev/null +++ b/data/migrations/Version20201102113208.php @@ -0,0 +1,86 @@ +getTable('short_urls'); + $this->skipIf($shortUrls->hasColumn(self::API_KEY_COLUMN)); + + $shortUrls->addColumn(self::API_KEY_COLUMN, Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => false, + ]); + + $shortUrls->addForeignKeyConstraint('api_keys', [self::API_KEY_COLUMN], ['id'], [ + 'onDelete' => 'SET NULL', + 'onUpdate' => 'RESTRICT', + ], 'FK_' . self::API_KEY_COLUMN); + } + + public function postUp(Schema $schema): void + { + // If there's only one API key and it's active, link all existing URLs with it + $qb = $this->connection->createQueryBuilder(); + $qb->select('id') + ->from('api_keys') + ->where($qb->expr()->eq('enabled', ':enabled')) + ->andWhere($qb->expr()->or( + $qb->expr()->isNull('expiration_date'), + $qb->expr()->gt('expiration_date', ':expiration'), + )) + ->setParameters([ + 'enabled' => true, + 'expiration' => Chronos::now()->toDateTimeString(), + ]); + + /** @var Result $result */ + $result = $qb->execute(); + $id = $this->resolveOneApiKeyId($result); + if ($id === null) { + return; + } + + $qb = $this->connection->createQueryBuilder(); + $qb->update('short_urls') + ->set(self::API_KEY_COLUMN, ':apiKeyId') + ->setParameter('apiKeyId', $id) + ->execute(); + } + + private function resolveOneApiKeyId(Result $result): ?string + { + $results = []; + while ($row = $result->fetchAssociative()) { + // As soon as we have to iterate more than once, then we cannot resolve a single API key + if (! empty($results)) { + return null; + } + + $results[] = $row['id'] ?? null; + } + + return $results[0] ?? null; + } + + public function down(Schema $schema): void + { + $shortUrls = $schema->getTable('short_urls'); + $this->skipIf(! $shortUrls->hasColumn(self::API_KEY_COLUMN)); + + $shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN); + $shortUrls->dropColumn(self::API_KEY_COLUMN); + } +} diff --git a/docker/README.md b/docker/README.md index 89c9565b..2cc0b5b9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -176,15 +176,17 @@ This is the complete list of supported env vars: * `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations. * `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302. * `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30. +* `PORT`: Can be used to set the port in which shlink listens. Defaults to 8080 (Some cloud providers, like Google cloud or Heroku, expect to be able to customize exposed port by providing this env var). An example using all env vars could look like this: ```bash docker run \ --name shlink \ - -p 8080:8080 \ + -p 8080:8888 \ -e SHORT_DOMAIN_HOST=doma.in \ -e SHORT_DOMAIN_SCHEMA=https \ + -e PORT=8888 \ -e DB_DRIVER=mysql \ -e DB_NAME=shlink \ -e DB_USER=root \ @@ -257,7 +259,8 @@ The whole configuration should have this format, but it can be split into multip "mercure_jwt_secret": "super_secret_key", "anonymize_remote_addr": false, "redirect_status_code": 301, - "redirect_cache_lifetime": 90 + "redirect_cache_lifetime": 90, + "port": 8888 } ``` diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 8d3c55fa..8efee8a4 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -159,6 +159,7 @@ return [ 'mezzio-swoole' => [ 'swoole-http-server' => [ + 'port' => (int) env('PORT', 8080), 'options' => [ 'worker_num' => (int) env('WEB_WORKER_NUM', 16), 'task_worker_num' => (int) env('TASK_WORKER_NUM', 16), diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index ee8a6060..a89dd187 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -31,7 +31,7 @@ { "name": "tags[]", "in": "query", - "description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)", + "description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)", "required": false, "schema": { "type": "array", @@ -48,10 +48,14 @@ "schema": { "type": "string", "enum": [ - "longUrl", - "shortCode", - "dateCreated", - "visits" + "longUrl-ASC", + "longUrl-DESC", + "shortCode-ASC", + "shortCode-DESC", + "dateCreated-ASC", + "dateCreated-DESC", + "visits-ASC", + "visits-DESC" ] } }, @@ -247,6 +251,10 @@ "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" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 71a6a427..c7e7dc8a 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -127,6 +127,10 @@ "maxVisits": { "description": "The maximum number of allowed visits for this short code", "type": "number" + }, + "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", + "type": "boolean" } } } diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 83bc7d68..cb6a6bb3 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -87,12 +87,13 @@ }, "post": { + "deprecated": true, "operationId": "createTags", "tags": [ "Tags" ], "summary": "Create tags", - "description": "Provided a list of tags, creates all that do not yet exist", + "description": "Provided a list of tags, creates all that do not yet exist
This endpoint is deprecated, as tags are automatically created while creating a short URL", "security": [ { "ApiKey": [] diff --git a/docs/swagger/paths/v2_domains.json b/docs/swagger/paths/v2_domains.json new file mode 100644 index 00000000..d92ae995 --- /dev/null +++ b/docs/swagger/paths/v2_domains.json @@ -0,0 +1,86 @@ +{ + "get": { + "operationId": "listDomains", + "tags": [ + "Domains" + ], + "summary": "List existing domains", + "description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain", + "security": [ + { + "ApiKey": [] + } + ], + "parameters": [ + { + "$ref": "../parameters/version.json" + } + ], + "responses": { + "200": { + "description": "The list of tags", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["domains"], + "properties": { + "domains": { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": ["domain", "isDefault"], + "properties": { + "domain": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + }, + "examples": { + "application/json": { + "domains": { + "data": [ + { + "domain": "example.com", + "isDefault": true + }, + { + "domain": "aaa.com", + "isDefault": false + }, + { + "domain": "bbb.com", + "isDefault": false + } + ] + } + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 300a7429..a3fdaffb 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -27,6 +27,19 @@ "maximum": 1000, "default": 300 } + }, + { + "name": "format", + "in": "query", + "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "png", + "svg" + ] + } } ], "responses": { @@ -38,6 +51,12 @@ "type": "string", "format": "binary" } + }, + "image/svg+xml": { + "schema": { + "type": "string", + "format": "binary" + } } } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 8dc21997..5abe1946 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -88,6 +88,10 @@ "$ref": "paths/v2_tags_{tag}_visits.json" }, + "/rest/v{version}/domains": { + "$ref": "paths/v2_domains.json" + }, + "/rest/v{version}/mercure-info": { "$ref": "paths/v2_mercure-info.json" }, diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index fa9efc69..6e32428a 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -25,6 +25,8 @@ return [ Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, + Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class, + Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 516bbbd4..199d29ef 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; +use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Core\Visit; @@ -52,6 +53,8 @@ return [ Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class, + + Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, ], ], @@ -84,6 +87,8 @@ return [ Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], + Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, SymfonyCli\Helper\ProcessHelper::class, diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php new file mode 100644 index 00000000..0368f1dd --- /dev/null +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -0,0 +1,49 @@ +domainService = $domainService; + $this->defaultDomain = $defaultDomain; + } + + protected function configure(): void + { + $this + ->setName(self::NAME) + ->setDescription('List all domains that have been ever used for some short URL'); + } + + protected function execute(InputInterface $input, OutputInterface $output): ?int + { + $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + + ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [ + [$this->defaultDomain, 'Yes'], + ...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']), + ]); + + return ExitCodes::EXIT_SUCCESS; + } +} diff --git a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php index 06cdd274..12bbb3fb 100644 --- a/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php @@ -21,7 +21,9 @@ use function array_map; use function Functional\curry; use function Functional\flatten; use function Functional\unique; +use function method_exists; use function sprintf; +use function strpos; class GenerateShortUrlCommand extends Command { @@ -94,6 +96,18 @@ class GenerateShortUrlCommand extends Command 'l', InputOption::VALUE_REQUIRED, 'The length for generated short code (it will be ignored if --customSlug was provided).', + ) + ->addOption( + 'validate-url', + null, + InputOption::VALUE_NONE, + 'Forces the long URL to be validated, regardless what is globally configured.', + ) + ->addOption( + 'no-validate-url', + null, + InputOption::VALUE_NONE, + 'Forces the long URL to not be validated, regardless what is globally configured.', ); } @@ -125,9 +139,10 @@ class GenerateShortUrlCommand extends Command $customSlug = $input->getOption('customSlug'); $maxVisits = $input->getOption('maxVisits'); $shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength; + $doValidateUrl = $this->doValidateUrl($input); try { - $shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, ShortUrlMeta::fromRawData([ + $shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([ ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'), ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'), ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug, @@ -135,6 +150,7 @@ class GenerateShortUrlCommand extends Command ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'), ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, + ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl, ])); $io->writeln([ @@ -147,4 +163,18 @@ class GenerateShortUrlCommand extends Command return ExitCodes::EXIT_FAILURE; } } + + private function doValidateUrl(InputInterface $input): ?bool + { + $rawInput = method_exists($input, '__toString') ? $input->__toString() : ''; + + if (strpos($rawInput, '--no-validate-url') !== false) { + return false; + } + if (strpos($rawInput, '--validate-url') !== false) { + return true; + } + + return null; + } } diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php index 451eb81e..0003319d 100644 --- a/module/CLI/src/Command/Tag/CreateTagCommand.php +++ b/module/CLI/src/Command/Tag/CreateTagCommand.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +/** @deprecated */ class CreateTagCommand extends Command { public const NAME = 'tag:create'; @@ -28,7 +29,7 @@ class CreateTagCommand extends Command { $this ->setName(self::NAME) - ->setDescription('Creates one or more tags.') + ->setDescription('[Deprecated] Creates one or more tags.') ->addOption( 'name', 't', diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 8b5ef6c4..49835f85 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; @@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester; class DisableKeyCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -37,7 +40,7 @@ class DisableKeyCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('API key "abcd1234" properly disabled', $output); + self::assertStringContainsString('API key "abcd1234" properly disabled', $output); } /** @test */ @@ -52,7 +55,7 @@ class DisableKeyCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString($expectedMessage, $output); + self::assertStringContainsString($expectedMessage, $output); $disable->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index 8ddd9f0b..7ff87a3f 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -16,6 +17,8 @@ use Symfony\Component\Console\Tester\CommandTester; class GenerateKeyCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -36,7 +39,7 @@ class GenerateKeyCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Generated API key: ', $output); + self::assertStringContainsString('Generated API key: ', $output); $create->shouldHaveBeenCalledOnce(); } diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 9e30605a..ccf3b0ee 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Api; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester; class ListKeysCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $apiKeyService; @@ -38,11 +41,11 @@ class ListKeysCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Key', $output); - $this->assertStringContainsString('Is enabled', $output); - $this->assertStringContainsString(' +++ ', $output); - $this->assertStringNotContainsString(' --- ', $output); - $this->assertStringContainsString('Expiration date', $output); + self::assertStringContainsString('Key', $output); + self::assertStringContainsString('Is enabled', $output); + self::assertStringContainsString(' +++ ', $output); + self::assertStringNotContainsString(' --- ', $output); + self::assertStringContainsString('Expiration date', $output); } /** @test */ @@ -58,10 +61,10 @@ class ListKeysCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Key', $output); - $this->assertStringNotContainsString('Is enabled', $output); - $this->assertStringNotContainsString(' +++ ', $output); - $this->assertStringNotContainsString(' --- ', $output); - $this->assertStringContainsString('Expiration date', $output); + self::assertStringContainsString('Key', $output); + self::assertStringNotContainsString('Is enabled', $output); + self::assertStringNotContainsString(' +++ ', $output); + self::assertStringNotContainsString(' --- ', $output); + self::assertStringContainsString('Expiration date', $output); } } diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index d890f264..8c325278 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -9,6 +9,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Symfony\Component\Console\Application; @@ -22,10 +23,11 @@ use Symfony\Component\Process\Process; class CreateDatabaseCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $processHelper; private ObjectProphecy $regularConn; - private ObjectProphecy $noDbNameConn; private ObjectProphecy $schemaManager; private ObjectProphecy $databasePlatform; @@ -48,15 +50,15 @@ class CreateDatabaseCommandTest extends TestCase $this->regularConn = $this->prophesize(Connection::class); $this->regularConn->getSchemaManager()->willReturn($this->schemaManager->reveal()); $this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal()); - $this->noDbNameConn = $this->prophesize(Connection::class); - $this->noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal()); + $noDbNameConn = $this->prophesize(Connection::class); + $noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal()); $command = new CreateDatabaseCommand( $locker->reveal(), $this->processHelper->reveal(), $phpExecutableFinder->reveal(), $this->regularConn->reveal(), - $this->noDbNameConn->reveal(), + $noDbNameConn->reveal(), ); $app = new Application(); $app->add($command); @@ -77,7 +79,7 @@ class CreateDatabaseCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Database already exists. Run "db:migrate" command', $output); + self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output); $getDatabase->shouldHaveBeenCalledOnce(); $listDatabases->shouldHaveBeenCalledOnce(); $createDatabase->shouldNotHaveBeenCalled(); @@ -121,8 +123,8 @@ class CreateDatabaseCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Creating database tables...', $output); - $this->assertStringContainsString('Database properly created!', $output); + self::assertStringContainsString('Creating database tables...', $output); + self::assertStringContainsString('Database properly created!', $output); $getDatabase->shouldHaveBeenCalledOnce(); $listDatabases->shouldHaveBeenCalledOnce(); $createDatabase->shouldNotHaveBeenCalled(); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 71587eea..9875c2f6 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Symfony\Component\Console\Application; @@ -19,6 +20,8 @@ use Symfony\Component\Process\Process; class MigrateDatabaseCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $processHelper; @@ -60,8 +63,8 @@ class MigrateDatabaseCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Migrating database...', $output); - $this->assertStringContainsString('Database properly migrated!', $output); + self::assertStringContainsString('Migrating database...', $output); + self::assertStringContainsString('Database properly migrated!', $output); $runCommand->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php new file mode 100644 index 00000000..500fed7f --- /dev/null +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -0,0 +1,59 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + + $command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com'); + $app = new Application(); + $app->add($command); + + $this->commandTester = new CommandTester($command); + } + + /** @test */ + public function allDomainsAreProperlyPrinted(): void + { + $expectedOutput = <<domainService->listDomainsWithout('foo.com')->willReturn([ + new Domain('bar.com'), + new Domain('baz.com'), + ]); + + $this->commandTester->execute([]); + + self::assertEquals($expectedOutput, $this->commandTester->getDisplay()); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + $listDomains->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 2c3526f5..83fd792d 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\Core\Exception; @@ -21,6 +22,8 @@ use const PHP_EOL; class DeleteShortUrlCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $service; @@ -47,7 +50,7 @@ class DeleteShortUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString( + self::assertStringContainsString( sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output, ); @@ -66,7 +69,7 @@ class DeleteShortUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); + self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); $deleteByShortCode->shouldHaveBeenCalledOnce(); } @@ -95,11 +98,11 @@ class DeleteShortUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf( + self::assertStringContainsString(sprintf( 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', $shortCode, ), $output); - $this->assertStringContainsString($expectedMessage, $output); + self::assertStringContainsString($expectedMessage, $output); $deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls); } @@ -122,11 +125,11 @@ class DeleteShortUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf( + self::assertStringContainsString(sprintf( 'Impossible to delete short URL with short code "%s" since it has more than "10" visits.', $shortCode, ), $output); - $this->assertStringContainsString('Short URL was not deleted.', $output); + self::assertStringContainsString('Short URL was not deleted.', $output); $deleteByShortCode->shouldHaveBeenCalledOnce(); } } diff --git a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php index 689a5e7c..82f38713 100644 --- a/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php @@ -7,18 +7,22 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortener; use Symfony\Component\Console\Application; use Symfony\Component\Console\Tester\CommandTester; class GenerateShortUrlCommandTest extends TestCase { + use ProphecyTrait; + private const DOMAIN_CONFIG = [ 'schema' => 'http', 'hostname' => 'foo.com', @@ -40,7 +44,7 @@ class GenerateShortUrlCommandTest extends TestCase public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void { $shortUrl = new ShortUrl(''); - $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn($shortUrl); + $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); $this->commandTester->execute([ 'longUrl' => 'http://domain.com/foo/bar', @@ -48,8 +52,8 @@ class GenerateShortUrlCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - $this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); $urlToShortCode->shouldHaveBeenCalledOnce(); } @@ -57,28 +61,28 @@ class GenerateShortUrlCommandTest extends TestCase public function exceptionWhileParsingLongUrlOutputsError(): void { $url = 'http://domain.com/invalid'; - $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url)) + $this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url)) ->shouldBeCalledOnce(); $this->commandTester->execute(['longUrl' => $url]); $output = $this->commandTester->getDisplay(); - $this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); - $this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); + self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); } /** @test */ public function providingNonUniqueSlugOutputsError(): void { - $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow( + $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow( NonUniqueSlugException::fromSlug('my-slug'), ); $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']); $output = $this->commandTester->getDisplay(); - $this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); - $this->assertStringContainsString('Provided slug "my-slug" is already in use', $output); + self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); + self::assertStringContainsString('Provided slug "my-slug" is already in use', $output); $urlToShortCode->shouldHaveBeenCalledOnce(); } @@ -86,7 +90,7 @@ class GenerateShortUrlCommandTest extends TestCase public function properlyProcessesProvidedTags(): void { $shortUrl = new ShortUrl(''); - $urlToShortCode = $this->urlShortener->urlToShortCode( + $urlToShortCode = $this->urlShortener->shorten( Argument::type('string'), Argument::that(function (array $tags) { Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags); @@ -101,8 +105,38 @@ class GenerateShortUrlCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); - $this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); + self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); + self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output); $urlToShortCode->shouldHaveBeenCalledOnce(); } + + /** + * @test + * @dataProvider provideFlags + */ + public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void + { + $shortUrl = new ShortUrl(''); + $urlToShortCode = $this->urlShortener->shorten( + Argument::type('string'), + Argument::type('array'), + Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) { + Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); + return $meta; + }), + )->willReturn($shortUrl); + + $options['longUrl'] = 'http://domain.com/foo/bar'; + $this->commandTester->execute($options); + + $urlToShortCode->shouldHaveBeenCalledOnce(); + } + + public function provideFlags(): iterable + { + yield 'no flags' => [[], null]; + yield 'no-validate-url only' => [['--no-validate-url' => true], false]; + yield 'validate-url' => [['--validate-url' => true], true]; + yield 'both flags' => [['--validate-url' => true, '--no-validate-url' => true], false]; + } } diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php index a725240e..9239544e 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php @@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; use Shlinkio\Shlink\Common\Util\DateRange; @@ -27,6 +28,8 @@ use function sprintf; class GetVisitsCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $visitsTracker; @@ -88,7 +91,7 @@ class GetVisitsCommandTest extends TestCase $output = $this->commandTester->getDisplay(); $info->shouldHaveBeenCalledOnce(); - $this->assertStringContainsString( + self::assertStringContainsString( sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate), $output, ); @@ -108,8 +111,8 @@ class GetVisitsCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('foo', $output); - $this->assertStringContainsString('Spain', $output); - $this->assertStringContainsString('bar', $output); + self::assertStringContainsString('foo', $output); + self::assertStringContainsString('Spain', $output); + self::assertStringContainsString('bar', $output); } } diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index d8bc0f60..918dc39a 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -21,6 +22,8 @@ use function explode; class ListShortUrlsCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $shortUrlService; @@ -50,9 +53,9 @@ class ListShortUrlsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Continue with page 2?', $output); - $this->assertStringContainsString('Continue with page 3?', $output); - $this->assertStringContainsString('Continue with page 4?', $output); + self::assertStringContainsString('Continue with page 2?', $output); + self::assertStringContainsString('Continue with page 3?', $output); + self::assertStringContainsString('Continue with page 4?', $output); } /** @test */ @@ -72,13 +75,13 @@ class ListShortUrlsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('url_1', $output); - $this->assertStringContainsString('url_9', $output); - $this->assertStringNotContainsString('url_10', $output); - $this->assertStringNotContainsString('url_20', $output); - $this->assertStringNotContainsString('url_30', $output); - $this->assertStringContainsString('Continue with page 2?', $output); - $this->assertStringNotContainsString('Continue with page 3?', $output); + self::assertStringContainsString('url_1', $output); + self::assertStringContainsString('url_9', $output); + self::assertStringNotContainsString('url_10', $output); + self::assertStringNotContainsString('url_20', $output); + self::assertStringNotContainsString('url_30', $output); + self::assertStringContainsString('Continue with page 2?', $output); + self::assertStringNotContainsString('Continue with page 3?', $output); } /** @test */ @@ -103,7 +106,7 @@ class ListShortUrlsCommandTest extends TestCase $this->commandTester->setInputs(['y']); $this->commandTester->execute(['--showTags' => true]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Tags', $output); + self::assertStringContainsString('Tags', $output); } /** diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 7c307252..a84a1ee3 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -20,6 +21,8 @@ use const PHP_EOL; class ResolveUrlCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $urlResolver; @@ -44,7 +47,7 @@ class ResolveUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); + self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); } /** @test */ @@ -59,6 +62,6 @@ class ResolveUrlCommandTest extends TestCase $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); + self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); } } diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php index e156cf28..2789c481 100644 --- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php +++ b/module/CLI/test/Command/Tag/CreateTagCommandTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; @@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester; class CreateTagCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -34,7 +37,7 @@ class CreateTagCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('You have to provide at least one tag name', $output); + self::assertStringContainsString('You have to provide at least one tag name', $output); } /** @test */ @@ -48,7 +51,7 @@ class CreateTagCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Tags properly created', $output); + self::assertStringContainsString('Tags properly created', $output); $createTags->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index 27a95de8..6d3737c1 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; @@ -13,6 +14,8 @@ use Symfony\Component\Console\Tester\CommandTester; class DeleteTagsCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -33,7 +36,7 @@ class DeleteTagsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('You have to provide at least one tag name', $output); + self::assertStringContainsString('You have to provide at least one tag name', $output); } /** @test */ @@ -48,7 +51,7 @@ class DeleteTagsCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Tags properly deleted', $output); + self::assertStringContainsString('Tags properly deleted', $output); $deleteTags->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index b6087307..5b9e14e9 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\Core\Entity\Tag; @@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester; class ListTagsCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -37,7 +40,7 @@ class ListTagsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('No tags found', $output); + self::assertStringContainsString('No tags found', $output); $tagsInfo->shouldHaveBeenCalled(); } @@ -52,12 +55,12 @@ class ListTagsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('| foo', $output); - $this->assertStringContainsString('| bar', $output); - $this->assertStringContainsString('| 10 ', $output); - $this->assertStringContainsString('| 2 ', $output); - $this->assertStringContainsString('| 7 ', $output); - $this->assertStringContainsString('| 32 ', $output); + self::assertStringContainsString('| foo', $output); + self::assertStringContainsString('| bar', $output); + self::assertStringContainsString('| 10 ', $output); + self::assertStringContainsString('| 2 ', $output); + self::assertStringContainsString('| 7 ', $output); + self::assertStringContainsString('| 32 ', $output); $tagsInfo->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index ee499c48..9764a111 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Tag; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\Core\Entity\Tag; @@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester; class RenameTagCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $tagService; @@ -42,7 +45,7 @@ class RenameTagCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Tag with name "foo" could not be found', $output); + self::assertStringContainsString('Tag with name "foo" could not be found', $output); $renameTag->shouldHaveBeenCalled(); } @@ -59,7 +62,7 @@ class RenameTagCommandTest extends TestCase ]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Tag properly renamed', $output); + self::assertStringContainsString('Tag properly renamed', $output); $renameTag->shouldHaveBeenCalled(); } } diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 803ae472..bb9f4715 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; @@ -32,10 +33,11 @@ use const PHP_EOL; class LocateVisitsCommandTest extends TestCase { + use ProphecyTrait; + private CommandTester $commandTester; private ObjectProphecy $visitService; private ObjectProphecy $ipResolver; - private ObjectProphecy $locker; private ObjectProphecy $lock; private ObjectProphecy $dbUpdater; @@ -45,17 +47,17 @@ class LocateVisitsCommandTest extends TestCase $this->ipResolver = $this->prophesize(IpLocationResolverInterface::class); $this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class); - $this->locker = $this->prophesize(Lock\LockFactory::class); + $locker = $this->prophesize(Lock\LockFactory::class); $this->lock = $this->prophesize(Lock\LockInterface::class); $this->lock->acquire(false)->willReturn(true); $this->lock->release()->will(function (): void { }); - $this->locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal()); + $locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal()); $command = new LocateVisitsCommand( $this->visitService->reveal(), $this->ipResolver->reveal(), - $this->locker->reveal(), + $locker->reveal(), $this->dbUpdater->reveal(), ); $app = new Application(); @@ -92,11 +94,11 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester->execute($args); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('Processing IP 1.2.3.0', $output); + self::assertStringContainsString('Processing IP 1.2.3.0', $output); if ($expectWarningPrint) { - $this->assertStringContainsString('Continue at your own risk', $output); + self::assertStringContainsString('Continue at your own', $output); } else { - $this->assertStringNotContainsString('Continue at your own risk', $output); + self::assertStringNotContainsString('Continue at your own', $output); } $locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls); $locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls); @@ -132,11 +134,11 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString($message, $output); + self::assertStringContainsString($message, $output); if (empty($address)) { - $this->assertStringNotContainsString('Processing IP', $output); + self::assertStringNotContainsString('Processing IP', $output); } else { - $this->assertStringContainsString('Processing IP', $output); + self::assertStringContainsString('Processing IP', $output); } $locateVisits->shouldHaveBeenCalledOnce(); $resolveIpLocation->shouldNotHaveBeenCalled(); @@ -164,7 +166,7 @@ class LocateVisitsCommandTest extends TestCase $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('An error occurred while locating IP. Skipped', $output); + self::assertStringContainsString('An error occurred while locating IP. Skipped', $output); $locateVisits->shouldHaveBeenCalledOnce(); $resolveIpLocation->shouldHaveBeenCalledOnce(); } @@ -192,7 +194,7 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString( + self::assertStringContainsString( sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME), $output, ); @@ -222,11 +224,11 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString( + self::assertStringContainsString( sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'), $output, ); - $this->assertStringContainsString($expectedMessage, $output); + self::assertStringContainsString($expectedMessage, $output); $locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists); $checkDbUpdate->shouldHaveBeenCalledOnce(); } @@ -243,7 +245,7 @@ class LocateVisitsCommandTest extends TestCase $this->commandTester->execute(['--all' => true]); $output = $this->commandTester->getDisplay(); - $this->assertStringContainsString('The --all flag has no effect on its own', $output); + self::assertStringContainsString('The --all flag has no effect on its own', $output); } /** diff --git a/module/CLI/test/ConfigProviderTest.php b/module/CLI/test/ConfigProviderTest.php index baa4f311..42a8f504 100644 --- a/module/CLI/test/ConfigProviderTest.php +++ b/module/CLI/test/ConfigProviderTest.php @@ -17,11 +17,11 @@ class ConfigProviderTest extends TestCase } /** @test */ - public function confiIsProperlyReturned(): void + public function configIsProperlyReturned(): void { $config = ($this->configProvider)(); - $this->assertArrayHasKey('cli', $config); - $this->assertArrayHasKey('dependencies', $config); + self::assertArrayHasKey('cli', $config); + self::assertArrayHasKey('dependencies', $config); } } diff --git a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php index 21a1e006..33d7d76e 100644 --- a/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php +++ b/module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php @@ -20,13 +20,13 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase { $e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev); - $this->assertEquals($olderDbExists, $e->olderDbExists()); - $this->assertEquals( + self::assertEquals($olderDbExists, $e->olderDbExists()); + self::assertEquals( 'An error occurred while updating geolocation database, and an older version could not be found', $e->getMessage(), ); - $this->assertEquals(0, $e->getCode()); - $this->assertEquals($prev, $e->getPrevious()); + self::assertEquals(0, $e->getCode()); + self::assertEquals($prev, $e->getPrevious()); } public function provideCreateArgs(): iterable diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index 043349ab..ee0793bc 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Factory; use Laminas\ServiceManager\ServiceManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\Core\Options\AppOptions; @@ -15,6 +16,8 @@ use Symfony\Component\Console\Command\Command; class ApplicationFactoryTest extends TestCase { + use ProphecyTrait; + private ApplicationFactory $factory; public function setUp(): void @@ -37,9 +40,9 @@ class ApplicationFactoryTest extends TestCase $instance = ($this->factory)($sm); - $this->assertTrue($instance->has('foo')); - $this->assertTrue($instance->has('bar')); - $this->assertFalse($instance->has('baz')); + self::assertTrue($instance->has('foo')); + self::assertTrue($instance->has('bar')); + self::assertFalse($instance->has('baz')); } private function createServiceManager(array $config = []): ServiceManager diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/Util/GeolocationDbUpdaterTest.php index b5346629..71e05d8a 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/Util/GeolocationDbUpdaterTest.php @@ -9,6 +9,7 @@ use GeoIp2\Database\Reader; use MaxMind\Db\Reader\Metadata; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; @@ -22,35 +23,35 @@ use function range; class GeolocationDbUpdaterTest extends TestCase { + use ProphecyTrait; + private GeolocationDbUpdater $geolocationDbUpdater; private ObjectProphecy $dbUpdater; private ObjectProphecy $geoLiteDbReader; - private ObjectProphecy $locker; - private ObjectProphecy $lock; public function setUp(): void { $this->dbUpdater = $this->prophesize(DbUpdaterInterface::class); $this->geoLiteDbReader = $this->prophesize(Reader::class); - $this->locker = $this->prophesize(Lock\LockFactory::class); - $this->lock = $this->prophesize(Lock\LockInterface::class); - $this->lock->acquire(true)->willReturn(true); - $this->lock->release()->will(function (): void { + $locker = $this->prophesize(Lock\LockFactory::class); + $lock = $this->prophesize(Lock\LockInterface::class); + $lock->acquire(true)->willReturn(true); + $lock->release()->will(function (): void { }); - $this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); + $locker->createLock(Argument::type('string'))->willReturn($lock->reveal()); $this->geolocationDbUpdater = new GeolocationDbUpdater( $this->dbUpdater->reveal(), $this->geoLiteDbReader->reveal(), - $this->locker->reveal(), + $locker->reveal(), ); } /** @test */ public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void { - $mustBeUpdated = fn () => $this->assertTrue(true); + $mustBeUpdated = fn () => self::assertTrue(true); $prev = new RuntimeException(''); $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false); @@ -59,12 +60,12 @@ class GeolocationDbUpdaterTest extends TestCase try { $this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated); - $this->assertTrue(false); // If this is reached, the test will fail + self::assertTrue(false); // If this is reached, the test will fail } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ - $this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); - $this->assertSame($prev, $e->getPrevious()); - $this->assertFalse($e->olderDbExists()); + self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); + self::assertSame($prev, $e->getPrevious()); + self::assertFalse($e->olderDbExists()); } $fileExists->shouldHaveBeenCalledOnce(); @@ -95,12 +96,12 @@ class GeolocationDbUpdaterTest extends TestCase try { $this->geolocationDbUpdater->checkDbUpdate(); - $this->assertTrue(false); // If this is reached, the test will fail + self::assertTrue(false); // If this is reached, the test will fail } catch (Throwable $e) { /** @var GeolocationDbUpdateFailedException $e */ - $this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); - $this->assertSame($prev, $e->getPrevious()); - $this->assertTrue($e->olderDbExists()); + self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e); + self::assertSame($prev, $e->getPrevious()); + self::assertTrue($e->olderDbExists()); } $fileExists->shouldHaveBeenCalledOnce(); diff --git a/module/CLI/test/Util/ShlinkTableTest.php b/module/CLI/test/Util/ShlinkTableTest.php index 23c4eb32..71bff82b 100644 --- a/module/CLI/test/Util/ShlinkTableTest.php +++ b/module/CLI/test/Util/ShlinkTableTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Util; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use ReflectionObject; use Shlinkio\Shlink\CLI\Util\ShlinkTable; @@ -15,6 +16,8 @@ use Symfony\Component\Console\Output\OutputInterface; class ShlinkTableTest extends TestCase { + use ProphecyTrait; + private ShlinkTable $shlinkTable; private ObjectProphecy $baseTable; @@ -60,6 +63,6 @@ class ShlinkTableTest extends TestCase $baseTable = $ref->getProperty('baseTable'); $baseTable->setAccessible(true); - $this->assertInstanceOf(Table::class, $baseTable->getValue($instance)); + self::assertInstanceOf(Table::class, $baseTable->getValue($instance)); } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 46bf1735..5c7c0b54 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Mezzio\Template\TemplateRendererInterface; use Psr\EventDispatcher\EventDispatcherInterface; -use Shlinkio\Shlink\Core\Domain\Resolver; use Shlinkio\Shlink\Core\ErrorHandler; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; +use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; return [ @@ -31,16 +31,25 @@ return [ Tag\TagService::class => ConfigAbstractFactory::class, Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, + Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class, + Domain\DomainService::class => ConfigAbstractFactory::class, Util\UrlValidator::class => ConfigAbstractFactory::class, + Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Action\RedirectAction::class => ConfigAbstractFactory::class, Action\PixelAction::class => ConfigAbstractFactory::class, Action\QrCodeAction::class => ConfigAbstractFactory::class, - Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class, + ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class, Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, + + Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, + ], + + 'aliases' => [ + ImportedLinksProcessorInterface::class => Importer\ImportedLinksProcessor::class, ], ], @@ -53,7 +62,12 @@ return [ Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'], Options\UrlShortenerOptions::class => ['config.url_shortener'], - Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class], + Service\UrlShortener::class => [ + Util\UrlValidator::class, + 'em', + ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, + Service\ShortUrl\ShortCodeHelper::class, + ], Service\VisitsTracker::class => [ 'em', EventDispatcherInterface::class, @@ -69,8 +83,11 @@ return [ Service\ShortUrl\ShortUrlResolver::class, ], Service\ShortUrl\ShortUrlResolver::class => ['em'], + Service\ShortUrl\ShortCodeHelper::class => ['em'], + Domain\DomainService::class => ['em'], Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], + Util\DoctrineBatchHelper::class => ['em'], Action\RedirectAction::class => [ Service\ShortUrl\ShortUrlResolver::class, @@ -91,9 +108,16 @@ return [ 'Logger_Shlink', ], - Resolver\PersistenceDomainResolver::class => ['em'], + ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'], Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'], + + Importer\ImportedLinksProcessor::class => [ + 'em', + ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, + Service\ShortUrl\ShortCodeHelper::class, + Util\DoctrineBatchHelper::class, + ], ], ]; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index c6349b74..e3d8c3cf 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -11,7 +11,8 @@ use Doctrine\ORM\Mapping\ClassMetadata; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); - $builder->setTable(determineTableName('domains', $emConfig)); + $builder->setTable(determineTableName('domains', $emConfig)) + ->setCustomRepositoryClass(Domain\Repository\DomainRepository::class); $builder->createField('id', Types::BIGINT) ->columnName('id') diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php index 871ac113..da4506af 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; +use Shlinkio\Shlink\Rest\Entity\ApiKey; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); @@ -51,6 +52,16 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->nullable() ->build(); + $builder->createField('importSource', Types::STRING) + ->columnName('import_source') + ->nullable() + ->build(); + + $builder->createField('importOriginalShortCode', Types::STRING) + ->columnName('import_original_short_code') + ->nullable() + ->build(); + $builder->createOneToMany('visits', Entity\Visit::class) ->mappedBy('shortUrl') ->fetchExtraLazy() @@ -68,5 +79,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->cascadePersist() ->build(); + $builder->createManyToOne('authorApiKey', ApiKey::class) + ->addJoinColumn('author_api_key_id', 'id', true, false, 'SET NULL') + ->build(); + $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); }; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 5b6657d1..2f7f86e9 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core; use Cake\Chronos\Chronos; use DateTimeInterface; use Fig\Http\Message\StatusCodeInterface; +use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use function sprintf; @@ -62,3 +63,15 @@ function determineTableName(string $tableName, array $emConfig = []): string return sprintf('%s.%s', $schema, $tableName); } + +function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int +{ + $value = $inputFilter->getValue($fieldName); + return $value !== null ? (int) $value : null; +} + +function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool +{ + $value = $inputFilter->getValue($fieldName); + return $value !== null ? (bool) $value : null; +} diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 979e34fe..4a8b7db5 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\QrCode; +use Endroid\QrCode\Writer\SvgWriter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; @@ -51,6 +52,11 @@ class QrCodeAction implements MiddlewareInterface $qrCode->setSize($this->getSizeParam($request)); $qrCode->setMargin(0); + $format = $request->getQueryParams()['format'] ?? 'png'; + if ($format === 'svg') { + $qrCode->setWriter(new SvgWriter()); + } + return new QrCodeResponse($qrCode); } diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php index 38fbdea3..aebeb2c3 100644 --- a/module/Core/src/Config/SimplifiedConfigParser.php +++ b/module/Core/src/Config/SimplifiedConfigParser.php @@ -40,6 +40,7 @@ class SimplifiedConfigParser 'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'], 'redirect_status_code' => ['url_shortener', 'redirect_status_code'], 'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'], + 'port' => ['mezzio-swoole', 'swoole-http-server', 'port'], ]; private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ 'delete_short_url_threshold' => [ diff --git a/module/Core/src/Domain/DomainService.php b/module/Core/src/Domain/DomainService.php new file mode 100644 index 00000000..d7575361 --- /dev/null +++ b/module/Core/src/Domain/DomainService.php @@ -0,0 +1,29 @@ +em = $em; + } + + /** + * @return Domain[] + */ + public function listDomainsWithout(?string $excludeDomain = null): array + { + /** @var DomainRepositoryInterface $repo */ + $repo = $this->em->getRepository(Domain::class); + return $repo->findDomainsWithout($excludeDomain); + } +} diff --git a/module/Core/src/Domain/DomainServiceInterface.php b/module/Core/src/Domain/DomainServiceInterface.php new file mode 100644 index 00000000..3e56c69c --- /dev/null +++ b/module/Core/src/Domain/DomainServiceInterface.php @@ -0,0 +1,15 @@ +createQueryBuilder('d')->orderBy('d.authority', 'ASC'); + + if ($excludedAuthority !== null) { + $qb->where($qb->expr()->neq('d.authority', ':excludedAuthority')) + ->setParameter('excludedAuthority', $excludedAuthority); + } + + return $qb->getQuery()->getResult(); + } +} diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php new file mode 100644 index 00000000..56a765ac --- /dev/null +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -0,0 +1,16 @@ +longUrl = $longUrl; $this->dateCreated = Chronos::now(); @@ -54,7 +58,29 @@ class ShortUrl extends AbstractEntity $this->customSlugWasProvided = $meta->hasCustomSlug(); $this->shortCodeLength = $meta->getShortCodeLength(); $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength); - $this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain()); + $this->domain = $relationResolver->resolveDomain($meta->getDomain()); + $this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey()); + } + + public static function fromImport( + ImportedShlinkUrl $url, + bool $importShortCode, + ?ShortUrlRelationResolverInterface $relationResolver = null + ): self { + $meta = [ + ShortUrlMetaInputFilter::DOMAIN => $url->domain(), + ShortUrlMetaInputFilter::VALIDATE_URL => false, + ]; + if ($importShortCode) { + $meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode(); + } + + $instance = new self($url->longUrl(), ShortUrlMeta::fromRawData($meta), $relationResolver); + $instance->importSource = $url->source(); + $instance->importOriginalShortCode = $url->shortCode(); + $instance->dateCreated = Chronos::instance($url->createdAt()); + + return $instance; } public function getLongUrl(): string @@ -113,10 +139,10 @@ class ShortUrl extends AbstractEntity /** * @throws ShortCodeCannotBeRegeneratedException */ - public function regenerateShortCode(): self + public function regenerateShortCode(): void { - // In ShortUrls where a custom slug was provided, do nothing - if ($this->customSlugWasProvided) { + // In ShortUrls where a custom slug was provided, throw error, unless it is an imported one + if ($this->customSlugWasProvided && $this->importSource === null) { throw ShortCodeCannotBeRegeneratedException::forShortUrlWithCustomSlug(); } @@ -126,7 +152,6 @@ class ShortUrl extends AbstractEntity } $this->shortCode = generateRandomShortCode($this->shortCodeLength); - return $this; } public function getValidSince(): ?Chronos @@ -195,27 +220,4 @@ class ShortUrl extends AbstractEntity return $this->domain->getAuthority(); } - - public function matchesCriteria(ShortUrlMeta $meta, array $tags): bool - { - if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $this->maxVisits) { - return false; - } - if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) { - return false; - } - if ($meta->hasValidSince() && ($this->validSince === null || ! $meta->getValidSince()->eq($this->validSince))) { - return false; - } - if ($meta->hasValidUntil() && ($this->validUntil === null || ! $meta->getValidUntil()->eq($this->validUntil))) { - return false; - } - - $shortUrlTags = invoke($this->getTags(), '__toString'); - return count($shortUrlTags) === count($tags) && array_reduce( - $tags, - fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag), - true, - ); - } } diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php new file mode 100644 index 00000000..8bac7395 --- /dev/null +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -0,0 +1,98 @@ +em = $em; + $this->relationResolver = $relationResolver; + $this->shortCodeHelper = $shortCodeHelper; + $this->batchHelper = $batchHelper; + } + + /** + * @param iterable|ImportedShlinkUrl[] $shlinkUrls + */ + public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void + { + /** @var ShortUrlRepositoryInterface $shortUrlRepo */ + $shortUrlRepo = $this->em->getRepository(ShortUrl::class); + $importShortCodes = $params['import_short_codes']; + $iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100); + + /** @var ImportedShlinkUrl $url */ + foreach ($iterable as $url) { + $longUrl = $url->longUrl(); + + // Skip already imported URLs + if ($shortUrlRepo->importedUrlExists($url)) { + $io->text(sprintf('%s: Skipped', $longUrl)); + continue; + } + + $shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver); + $shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags())); + + if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) { + continue; + } + + $this->em->persist($shortUrl); + $io->text(sprintf('%s: Imported', $longUrl)); + } + } + + private function handleShortCodeUniqueness( + ImportedShlinkUrl $url, + ShortUrl $shortUrl, + StyleInterface $io, + bool $importShortCodes + ): bool { + if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) { + return true; + } + + $longUrl = $url->longUrl(); + $action = $io->choice(sprintf( + 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate a new ' + . 'one or skip it?', + $longUrl, + $url->shortCode(), + ), ['Generate new short-code', 'Skip'], 1); + + if ($action === 'Skip') { + $io->text(sprintf('%s: Skipped', $longUrl)); + return false; + } + + return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false); + } +} diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index 2f3f6919..67300682 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -9,6 +9,8 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; use function array_key_exists; +use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; +use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\parseDateField; final class ShortUrlEdit @@ -21,6 +23,7 @@ final class ShortUrlEdit private ?Chronos $validUntil = null; private bool $maxVisitsPropWasProvided = false; private ?int $maxVisits = null; + private ?bool $validateUrl = null; // Enforce named constructors private function __construct() @@ -55,13 +58,8 @@ final class ShortUrlEdit $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL); $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); - $this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); - } - - private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int - { - $value = $inputFilter->getValue($fieldName); - return $value !== null ? (int) $value : null; + $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); + $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL); } public function longUrl(): ?string @@ -103,4 +101,9 @@ final class ShortUrlEdit { return $this->maxVisitsPropWasProvided; } + + public function doValidateUrl(): ?bool + { + return $this->validateUrl; + } } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 76f6d80b..fa82919e 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -8,6 +8,8 @@ use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; +use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\parseDateField; use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH; @@ -21,6 +23,8 @@ final class ShortUrlMeta private ?bool $findIfExists = null; private ?string $domain = null; private int $shortCodeLength = 5; + private ?bool $validateUrl = null; + private ?string $apiKey = null; // Enforce named constructors private function __construct() @@ -55,19 +59,15 @@ final class ShortUrlMeta $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE)); $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL)); $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG); - $this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); + $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS); $this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS); + $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL); $this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN); - $this->shortCodeLength = $this->getOptionalIntFromInputFilter( + $this->shortCodeLength = getOptionalIntFromInputFilter( $inputFilter, ShortUrlMetaInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH; - } - - private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int - { - $value = $inputFilter->getValue($fieldName); - return $value !== null ? (int) $value : null; + $this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY); } public function getValidSince(): ?Chronos @@ -129,4 +129,14 @@ final class ShortUrlMeta { return $this->shortCodeLength; } + + public function doValidateUrl(): ?bool + { + return $this->validateUrl; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } } diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index 00c30a54..25c7c940 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Core\Exception\ValidationException; +use function explode; use function is_array; use function is_string; use function key; @@ -40,15 +41,22 @@ final class ShortUrlsOrdering return; } + // FIXME Providing the ordering as array is considered deprecated. To be removed in v3.0.0 $isArray = is_array($orderBy); - if (! $isArray && $orderBy !== null && ! is_string($orderBy)) { + if (! $isArray && ! is_string($orderBy)) { throw ValidationException::fromArray([ 'orderBy' => '"Order by" must be an array, string or null', ]); } - $this->orderField = $isArray ? key($orderBy) : $orderBy; - $this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION; + if (! $isArray) { + $parts = explode('-', $orderBy); + $this->orderField = $parts[0]; + $this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION; + } else { + $this->orderField = key($orderBy); + $this->orderDirection = $orderBy[$this->orderField]; + } } public function orderField(): ?string diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 31fe1385..27dac54b 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -5,13 +5,17 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; use function array_key_exists; +use function count; use function Functional\contains; class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface @@ -186,6 +190,85 @@ DQL; ->setParameter('slug', $slug) ->setMaxResults(1); + $this->whereDomainIs($qb, $domain); + + return $qb; + } + + public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb->select('s') + ->from(ShortUrl::class, 's') + ->where($qb->expr()->eq('s.longUrl', ':longUrl')) + ->setParameter('longUrl', $url) + ->setMaxResults(1) + ->orderBy('s.id'); + + if ($meta->hasCustomSlug()) { + $qb->andWhere($qb->expr()->eq('s.shortCode', ':slug')) + ->setParameter('slug', $meta->getCustomSlug()); + } + if ($meta->hasMaxVisits()) { + $qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits')) + ->setParameter('maxVisits', $meta->getMaxVisits()); + } + if ($meta->hasValidSince()) { + $qb->andWhere($qb->expr()->eq('s.validSince', ':validSince')) + ->setParameter('validSince', $meta->getValidSince()); + } + if ($meta->hasValidUntil()) { + $qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil')) + ->setParameter('validUntil', $meta->getValidUntil()); + } + + if ($meta->hasDomain()) { + $qb->join('s.domain', 'd') + ->andWhere($qb->expr()->eq('d.authority', ':domain')) + ->setParameter('domain', $meta->getDomain()); + } + + $tagsAmount = count($tags); + if ($tagsAmount === 0) { + return $qb->getQuery()->getOneOrNullResult(); + } + + foreach ($tags as $index => $tag) { + $alias = 't_' . $index; + $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index) + ->setParameter('tag' . $index, $tag); + } + + // If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we + // can discard those that also have more tags, making sure only those fully matching are included. + $qb->join('s.tags', 't') + ->groupBy('s') + ->having($qb->expr()->eq('COUNT(t.id)', ':tagsAmount')) + ->setParameter('tagsAmount', $tagsAmount); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function importedUrlExists(ImportedShlinkUrl $url): bool + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->select('COUNT(DISTINCT s.id)') + ->from(ShortUrl::class, 's') + ->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) + ->setParameter('shortCode', $url->shortCode()) + ->andWhere($qb->expr()->eq('s.importSource', ':importSource')) + ->setParameter('importSource', $url->source()) + ->setMaxResults(1); + + $this->whereDomainIs($qb, $url->domain()); + + $result = (int) $qb->getQuery()->getSingleScalarResult(); + return $result > 0; + } + + private function whereDomainIs(QueryBuilder $qb, ?string $domain): void + { if ($domain !== null) { $qb->join('s.domain', 'd') ->andWhere($qb->expr()->eq('d.authority', ':authority')) @@ -193,7 +276,5 @@ DQL; } else { $qb->andWhere($qb->expr()->isNull('s.domain')); } - - return $qb; } } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 065198b4..1d6f38a8 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Core\Repository; use Doctrine\Persistence\ObjectRepository; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository { @@ -27,4 +29,8 @@ interface ShortUrlRepositoryInterface extends ObjectRepository public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl; public function shortCodeIsInUse(string $slug, ?string $domain): bool; + + public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl; + + public function importedUrlExists(ImportedShlinkUrl $url): bool; } diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php new file mode 100644 index 00000000..6e4e57ac --- /dev/null +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelper.php @@ -0,0 +1,41 @@ +em = $em; + } + + public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool + { + $shortCode = $shortUrlToBeCreated->getShortCode(); + $domain = $shortUrlToBeCreated->getDomain(); + $domainAuthority = $domain !== null ? $domain->getAuthority() : null; + + /** @var ShortUrlRepository $repo */ + $repo = $this->em->getRepository(ShortUrl::class); + $otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domainAuthority); + + if (! $otherShortUrlsExist) { + return true; + } + + if ($hasCustomSlug) { + return false; + } + + $shortUrlToBeCreated->regenerateShortCode(); + return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug); + } +} diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php b/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php new file mode 100644 index 00000000..af3f2aa5 --- /dev/null +++ b/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php @@ -0,0 +1,12 @@ +hasLongUrl()) { - $this->urlValidator->validateUrl($shortUrlEdit->longUrl()); + $this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl()); } $shortUrl = $this->urlResolver->resolveShortUrl($identifier); diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 7892f959..3ed4d2df 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -5,34 +5,36 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; -use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; use Throwable; -use function array_reduce; - class UrlShortener implements UrlShortenerInterface { use TagManagerTrait; private EntityManagerInterface $em; private UrlValidatorInterface $urlValidator; - private DomainResolverInterface $domainResolver; + private ShortUrlRelationResolverInterface $relationResolver; + private ShortCodeHelperInterface $shortCodeHelper; public function __construct( UrlValidatorInterface $urlValidator, EntityManagerInterface $em, - DomainResolverInterface $domainResolver + ShortUrlRelationResolverInterface $relationResolver, + ShortCodeHelperInterface $shortCodeHelper ) { $this->urlValidator = $urlValidator; $this->em = $em; - $this->domainResolver = $domainResolver; + $this->relationResolver = $relationResolver; + $this->shortCodeHelper = $shortCodeHelper; } /** @@ -41,7 +43,7 @@ class UrlShortener implements UrlShortenerInterface * @throws InvalidUrlException * @throws Throwable */ - public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl + public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl { // First, check if a short URL exists for all provided params $existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta); @@ -49,26 +51,17 @@ class UrlShortener implements UrlShortenerInterface return $existingShortUrl; } - $this->urlValidator->validateUrl($url); - $this->em->beginTransaction(); - $shortUrl = new ShortUrl($url, $meta, $this->domainResolver); - $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); + $this->urlValidator->validateUrl($url, $meta->doValidateUrl()); + + return $this->em->transactional(function () use ($url, $tags, $meta) { + $shortUrl = new ShortUrl($url, $meta, $this->relationResolver); + $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); - try { $this->verifyShortCodeUniqueness($meta, $shortUrl); $this->em->persist($shortUrl); - $this->em->flush(); - $this->em->commit(); - } catch (Throwable $e) { - if ($this->em->getConnection()->isTransactionActive()) { - $this->em->rollback(); - $this->em->close(); - } - throw $e; - } - - return $shortUrl; + return $shortUrl; + }); } private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl @@ -77,42 +70,23 @@ class UrlShortener implements UrlShortenerInterface return null; } - $criteria = ['longUrl' => $url]; - if ($meta->hasCustomSlug()) { - $criteria['shortCode'] = $meta->getCustomSlug(); - } - /** @var ShortUrl[] $shortUrls */ - $shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria); - if (empty($shortUrls)) { - return null; - } - - // Iterate short URLs until one that matches is found, or return null otherwise - return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) { - if ($found !== null) { - return $found; - } - - return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null; - }); + /** @var ShortUrlRepositoryInterface $repo */ + $repo = $this->em->getRepository(ShortUrl::class); + return $repo->findOneMatching($url, $tags, $meta); } private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void { - $shortCode = $shortUrlToBeCreated->getShortCode(); - $domain = $meta->getDomain(); + $couldBeMadeUnique = $this->shortCodeHelper->ensureShortCodeUniqueness( + $shortUrlToBeCreated, + $meta->hasCustomSlug(), + ); - /** @var ShortUrlRepository $repo */ - $repo = $this->em->getRepository(ShortUrl::class); - $otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domain); + if (! $couldBeMadeUnique) { + $domain = $shortUrlToBeCreated->getDomain(); + $domainAuthority = $domain !== null ? $domain->getAuthority() : null; - if ($otherShortUrlsExist && $meta->hasCustomSlug()) { - throw NonUniqueSlugException::fromSlug($shortCode, $domain); - } - - if ($otherShortUrlsExist) { - $shortUrlToBeCreated->regenerateShortCode(); - $this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated); + throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority); } } } diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index e26530ca..45b1eb8a 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -16,5 +16,5 @@ interface UrlShortenerInterface * @throws NonUniqueSlugException * @throws InvalidUrlException */ - public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl; + public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl; } diff --git a/module/Core/src/Domain/Resolver/PersistenceDomainResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php similarity index 55% rename from module/Core/src/Domain/Resolver/PersistenceDomainResolver.php rename to module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index ca679d96..d898fb37 100644 --- a/module/Core/src/Domain/Resolver/PersistenceDomainResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Domain\Resolver; +namespace Shlinkio\Shlink\Core\ShortUrl\Resolver; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Core\Entity\Domain; +use Shlinkio\Shlink\Rest\Entity\ApiKey; -class PersistenceDomainResolver implements DomainResolverInterface +class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface { private EntityManagerInterface $em; @@ -26,4 +27,15 @@ class PersistenceDomainResolver implements DomainResolverInterface $existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]); return $existingDomain ?? new Domain($domain); } + + public function resolveApiKey(?string $key): ?ApiKey + { + if ($key === null) { + return null; + } + + /** @var ApiKey|null $existingApiKey */ + $existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]); + return $existingApiKey; + } } diff --git a/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php new file mode 100644 index 00000000..0a708cf6 --- /dev/null +++ b/module/Core/src/ShortUrl/Resolver/ShortUrlRelationResolverInterface.php @@ -0,0 +1,15 @@ +em = $em; + } + + /** + * @throws Throwable + */ + public function wrapIterable(iterable $resultSet, int $batchSize): iterable + { + $iteration = 0; + + $this->em->beginTransaction(); + + try { + foreach ($resultSet as $key => $value) { + $iteration++; + yield $key => $value; + $this->flushAndClearBatch($iteration, $batchSize); + } + } catch (Throwable $e) { + $this->em->rollback(); + + throw $e; + } + + $this->flushAndClearEntityManager(); + $this->em->commit(); + } + + private function flushAndClearBatch(int $iteration, int $batchSize): void + { + if ($iteration % $batchSize) { + return; + } + + $this->flushAndClearEntityManager(); + } + + private function flushAndClearEntityManager(): void + { + $this->em->flush(); + $this->em->clear(); + } +} diff --git a/module/Core/src/Util/DoctrineBatchHelperInterface.php b/module/Core/src/Util/DoctrineBatchHelperInterface.php new file mode 100644 index 00000000..941561ed --- /dev/null +++ b/module/Core/src/Util/DoctrineBatchHelperInterface.php @@ -0,0 +1,10 @@ +options->isUrlValidationEnabled()) { + // If the URL validation is not enabled or it was explicitly set to not validate, skip check + $doValidate = $doValidate ?? $this->options->isUrlValidationEnabled(); + if (! $doValidate) { return; } diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php index 05230605..fdf1e781 100644 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ b/module/Core/src/Util/UrlValidatorInterface.php @@ -11,5 +11,5 @@ interface UrlValidatorInterface /** * @throws InvalidUrlException */ - public function validateUrl(string $url): void; + public function validateUrl(string $url, ?bool $doValidate): void; } diff --git a/module/Core/src/Validation/ShortUrlMetaInputFilter.php b/module/Core/src/Validation/ShortUrlMetaInputFilter.php index 6d0cfffe..e3b630e4 100644 --- a/module/Core/src/Validation/ShortUrlMetaInputFilter.php +++ b/module/Core/src/Validation/ShortUrlMetaInputFilter.php @@ -27,6 +27,8 @@ class ShortUrlMetaInputFilter extends InputFilter public const DOMAIN = 'domain'; public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const LONG_URL = 'longUrl'; + public const VALIDATE_URL = 'validateUrl'; + public const API_KEY = 'apiKey'; public function __construct(array $data) { @@ -64,9 +66,13 @@ class ShortUrlMetaInputFilter extends InputFilter $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); + $this->add($this->createInput(self::VALIDATE_URL, false)); + $domain = $this->createInput(self::DOMAIN, false); $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); + + $this->add($this->createInput(self::API_KEY, false)); } private function createPositiveNumberInput(string $name, int $min = 1): Input diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php new file mode 100644 index 00000000..b79f15f1 --- /dev/null +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -0,0 +1,39 @@ +repo = $this->getEntityManager()->getRepository(Domain::class); + } + + /** @test */ + public function findDomainsReturnsExpectedResult(): void + { + $fooDomain = new Domain('foo.com'); + $barDomain = new Domain('bar.com'); + $bazDomain = new Domain('baz.com'); + + $this->getEntityManager()->persist($fooDomain); + $this->getEntityManager()->persist($barDomain); + $this->getEntityManager()->persist($bazDomain); + $this->getEntityManager()->flush(); + + self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout()); + self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com')); + self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com')); + self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com')); + } +} diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 4829fada..86eb2aa3 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -16,12 +16,16 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\Util\TagManagerTrait; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function count; class ShortUrlRepositoryTest extends DatabaseTestCase { + use TagManagerTrait; + protected const ENTITIES_TO_EMPTY = [ Tag::class, Visit::class, @@ -54,25 +58,25 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $this->assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode())); - $this->assertSame($regularOne, $this->repo->findOneWithDomainFallback( + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode())); + self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( $withDomainDuplicatingRegular->getShortCode(), )); - $this->assertSame($withDomain, $this->repo->findOneWithDomainFallback( + self::assertSame($withDomain, $this->repo->findOneWithDomainFallback( $withDomain->getShortCode(), 'example.com', )); - $this->assertSame( + self::assertSame( $withDomainDuplicatingRegular, $this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'doma.in'), ); - $this->assertSame( + self::assertSame( $regularOne, $this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'), ); - $this->assertNull($this->repo->findOneWithDomainFallback('invalid')); - $this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode())); - $this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com')); + self::assertNull($this->repo->findOneWithDomainFallback('invalid')); + self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode())); + self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com')); } /** @test */ @@ -84,7 +88,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $this->assertEquals($count, $this->repo->countList()); + self::assertEquals($count, $this->repo->countList()); } /** @test */ @@ -113,37 +117,37 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $result = $this->repo->findList(null, null, 'foo', ['bar']); - $this->assertCount(1, $result); - $this->assertEquals(1, $this->repo->countList('foo', ['bar'])); - $this->assertSame($foo, $result[0]); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList('foo', ['bar'])); + self::assertSame($foo, $result[0]); $result = $this->repo->findList(); - $this->assertCount(3, $result); + self::assertCount(3, $result); $result = $this->repo->findList(2); - $this->assertCount(2, $result); + self::assertCount(2, $result); $result = $this->repo->findList(2, 1); - $this->assertCount(2, $result); + self::assertCount(2, $result); - $this->assertCount(1, $this->repo->findList(2, 2)); + self::assertCount(1, $this->repo->findList(2, 2)); $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([ 'orderBy' => ['visits' => 'DESC'], ])); - $this->assertCount(3, $result); - $this->assertSame($bar, $result[0]); + self::assertCount(3, $result); + self::assertSame($bar, $result[0]); $result = $this->repo->findList(null, null, null, [], null, new DateRange(null, Chronos::now()->subDays(2))); - $this->assertCount(1, $result); - $this->assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2)))); - $this->assertSame($foo2, $result[0]); + self::assertCount(1, $result); + self::assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2)))); + self::assertSame($foo2, $result[0]); - $this->assertCount( + self::assertCount( 2, $this->repo->findList(null, null, null, [], null, new DateRange(Chronos::now()->subDays(2))), ); - $this->assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2)))); + self::assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2)))); } /** @test */ @@ -160,11 +164,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase 'orderBy' => ['longUrl' => 'ASC'], ])); - $this->assertCount(count($urls), $result); - $this->assertEquals('a', $result[0]->getLongUrl()); - $this->assertEquals('b', $result[1]->getLongUrl()); - $this->assertEquals('c', $result[2]->getLongUrl()); - $this->assertEquals('z', $result[3]->getLongUrl()); + self::assertCount(count($urls), $result); + self::assertEquals('a', $result[0]->getLongUrl()); + self::assertEquals('b', $result[1]->getLongUrl()); + self::assertEquals('c', $result[2]->getLongUrl()); + self::assertEquals('z', $result[3]->getLongUrl()); } /** @test */ @@ -181,12 +185,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $this->assertTrue($this->repo->shortCodeIsInUse('my-cool-slug')); - $this->assertFalse($this->repo->shortCodeIsInUse('my-cool-slug', 'doma.in')); - $this->assertFalse($this->repo->shortCodeIsInUse('slug-not-in-use')); - $this->assertFalse($this->repo->shortCodeIsInUse('another-slug')); - $this->assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com')); - $this->assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in')); + self::assertTrue($this->repo->shortCodeIsInUse('my-cool-slug')); + self::assertFalse($this->repo->shortCodeIsInUse('my-cool-slug', 'doma.in')); + self::assertFalse($this->repo->shortCodeIsInUse('slug-not-in-use')); + self::assertFalse($this->repo->shortCodeIsInUse('another-slug')); + self::assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com')); + self::assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in')); } /** @test */ @@ -203,11 +207,140 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $this->assertNotNull($this->repo->findOne('my-cool-slug')); - $this->assertNull($this->repo->findOne('my-cool-slug', 'doma.in')); - $this->assertNull($this->repo->findOne('slug-not-in-use')); - $this->assertNull($this->repo->findOne('another-slug')); - $this->assertNull($this->repo->findOne('another-slug', 'example.com')); - $this->assertNotNull($this->repo->findOne('another-slug', 'doma.in')); + self::assertNotNull($this->repo->findOne('my-cool-slug')); + self::assertNull($this->repo->findOne('my-cool-slug', 'doma.in')); + self::assertNull($this->repo->findOne('slug-not-in-use')); + self::assertNull($this->repo->findOne('another-slug')); + self::assertNull($this->repo->findOne('another-slug', 'example.com')); + self::assertNotNull($this->repo->findOne('another-slug', 'doma.in')); + } + + /** @test */ + public function findOneMatchingReturnsNullForNonExistingShortUrls(): void + { + self::assertNull($this->repo->findOneMatching('', [], ShortUrlMeta::createEmpty())); + self::assertNull($this->repo->findOneMatching('foobar', [], ShortUrlMeta::createEmpty())); + self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::createEmpty())); + self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::fromRawData([ + 'validSince' => Chronos::parse('2020-03-05 20:18:30'), + 'customSlug' => 'this_slug_does_not_exist', + ]))); + } + + /** @test */ + public function findOneMatchingAppliesProperConditions(): void + { + $start = Chronos::parse('2020-03-05 20:18:30'); + $end = Chronos::parse('2021-03-05 20:18:30'); + + $shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(['validSince' => $start])); + $shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar'])); + $this->getEntityManager()->persist($shortUrl); + + $shortUrl2 = new ShortUrl('bar', ShortUrlMeta::fromRawData(['validUntil' => $end])); + $this->getEntityManager()->persist($shortUrl2); + + $shortUrl3 = new ShortUrl('baz', ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end])); + $this->getEntityManager()->persist($shortUrl3); + + $shortUrl4 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'validUntil' => $end])); + $this->getEntityManager()->persist($shortUrl4); + + $shortUrl5 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['maxVisits' => 3])); + $this->getEntityManager()->persist($shortUrl5); + + $shortUrl6 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['domain' => 'doma.in'])); + $this->getEntityManager()->persist($shortUrl6); + + $this->getEntityManager()->flush(); + + self::assertSame( + $shortUrl, + $this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])), + ); + self::assertSame( + $shortUrl2, + $this->repo->findOneMatching('bar', [], ShortUrlMeta::fromRawData(['validUntil' => $end])), + ); + self::assertSame( + $shortUrl3, + $this->repo->findOneMatching('baz', [], ShortUrlMeta::fromRawData([ + 'validSince' => $start, + 'validUntil' => $end, + ])), + ); + self::assertSame( + $shortUrl4, + $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData([ + 'customSlug' => 'custom', + 'validUntil' => $end, + ])), + ); + self::assertSame( + $shortUrl5, + $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['maxVisits' => 3])), + ); + self::assertSame( + $shortUrl6, + $this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['domain' => 'doma.in'])), + ); + } + + /** @test */ + public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void + { + $start = Chronos::parse('2020-03-05 20:18:30'); + $meta = ['validSince' => $start, 'maxVisits' => 50]; + $tags = ['foo', 'bar']; + $tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags); + + $shortUrl1 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl1->setTags($tagEntities); + $this->getEntityManager()->persist($shortUrl1); + + $shortUrl2 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl2->setTags($tagEntities); + $this->getEntityManager()->persist($shortUrl2); + + $shortUrl3 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta)); + $shortUrl3->setTags($tagEntities); + $this->getEntityManager()->persist($shortUrl3); + + $this->getEntityManager()->flush(); + + self::assertSame( + $shortUrl1, + $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + ); + self::assertNotSame( + $shortUrl2, + $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + ); + self::assertNotSame( + $shortUrl3, + $this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)), + ); + } + + /** @test */ + public function importedShortUrlsAreSearchedAsExpected(): void + { + $buildImported = static fn (string $shortCode, ?String $domain = null) => + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode); + + $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); + $this->getEntityManager()->persist($shortUrlWithoutDomain); + + $shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 'doma.in'), true); + $this->getEntityManager()->persist($shortUrlWithDomain); + + $this->getEntityManager()->flush(); + + self::assertTrue($this->repo->importedUrlExists($buildImported('my-cool-slug'))); + self::assertTrue($this->repo->importedUrlExists($buildImported('another-slug', 'doma.in'))); + self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug'))); + self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug', 'doma.in'))); + self::assertFalse($this->repo->importedUrlExists($buildImported('my-cool-slug', 'doma.in'))); + self::assertFalse($this->repo->importedUrlExists($buildImported('another-slug'))); } } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 8e1a11ef..9f8b9893 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -32,7 +32,7 @@ class TagRepositoryTest extends DatabaseTestCase /** @test */ public function deleteByNameDoesNothingWhenEmptyListIsProvided(): void { - $this->assertEquals(0, $this->repo->deleteByName([])); + self::assertEquals(0, $this->repo->deleteByName([])); } /** @test */ @@ -46,7 +46,7 @@ class TagRepositoryTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $this->assertEquals(2, $this->repo->deleteByName($toDelete)); + self::assertEquals(2, $this->repo->deleteByName($toDelete)); } /** @test */ @@ -79,20 +79,20 @@ class TagRepositoryTest extends DatabaseTestCase $result = $this->repo->findTagsWithInfo(); - $this->assertCount(4, $result); - $this->assertEquals( + self::assertCount(4, $result); + self::assertEquals( ['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0], $result[0]->jsonSerialize(), ); - $this->assertEquals( + self::assertEquals( ['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3], $result[1]->jsonSerialize(), ); - $this->assertEquals( + self::assertEquals( ['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3], $result[2]->jsonSerialize(), ); - $this->assertEquals( + self::assertEquals( ['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4], $result[3]->jsonSerialize(), ); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 529a5ae0..f6df4b9b 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -75,9 +75,9 @@ class VisitRepositoryTest extends DatabaseTestCase // Important! assertCount will not work here, as this iterable object loads data dynamically and the count // is 0 if not iterated - $this->assertEquals(2, $countIterable($unlocated)); - $this->assertEquals(4, $countIterable($withEmptyLocation)); - $this->assertEquals(6, $countIterable($all)); + self::assertEquals(2, $countIterable($unlocated)); + self::assertEquals(4, $countIterable($withEmptyLocation)); + self::assertEquals(6, $countIterable($all)); } public function provideBlockSize(): iterable @@ -90,22 +90,22 @@ class VisitRepositoryTest extends DatabaseTestCase { [$shortCode, $domain] = $this->createShortUrlsAndVisits(); - $this->assertCount(0, $this->repo->findVisitsByShortCode('invalid')); - $this->assertCount(6, $this->repo->findVisitsByShortCode($shortCode)); - $this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain)); - $this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( + self::assertCount(0, $this->repo->findVisitsByShortCode('invalid')); + self::assertCount(6, $this->repo->findVisitsByShortCode($shortCode)); + self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain)); + self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - $this->assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( + self::assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange( Chronos::parse('2016-01-03'), ))); - $this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange( + self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange( Chronos::parse('2016-01-03'), ))); - $this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2)); - $this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4)); - $this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2)); + self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2)); + self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4)); + self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2)); } /** @test */ @@ -113,17 +113,17 @@ class VisitRepositoryTest extends DatabaseTestCase { [$shortCode, $domain] = $this->createShortUrlsAndVisits(); - $this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid')); - $this->assertEquals(6, $this->repo->countVisitsByShortCode($shortCode)); - $this->assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain)); - $this->assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( + self::assertEquals(0, $this->repo->countVisitsByShortCode('invalid')); + self::assertEquals(6, $this->repo->countVisitsByShortCode($shortCode)); + self::assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain)); + self::assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - $this->assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( + self::assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange( Chronos::parse('2016-01-03'), ))); - $this->assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange( + self::assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange( Chronos::parse('2016-01-03'), ))); } @@ -147,13 +147,13 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $this->assertCount(0, $this->repo->findVisitsByTag('invalid')); - $this->assertCount(18, $this->repo->findVisitsByTag((string) $foo)); - $this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange( + self::assertCount(0, $this->repo->findVisitsByTag('invalid')); + self::assertCount(18, $this->repo->findVisitsByTag((string) $foo)); + self::assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - $this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange( + self::assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange( Chronos::parse('2016-01-03'), ))); } @@ -174,13 +174,13 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $this->assertEquals(0, $this->repo->countVisitsByTag('invalid')); - $this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo)); - $this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange( + self::assertEquals(0, $this->repo->countVisitsByTag('invalid')); + self::assertEquals(12, $this->repo->countVisitsByTag((string) $foo)); + self::assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange( Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03'), ))); - $this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange( + self::assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange( Chronos::parse('2016-01-03'), ))); } diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index f4aed872..b1edd9ec 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\PixelResponse; @@ -19,6 +20,8 @@ use Shlinkio\Shlink\Core\Service\VisitsTracker; class PixelActionTest extends TestCase { + use ProphecyTrait; + private PixelAction $action; private ObjectProphecy $urlResolver; private ObjectProphecy $visitTracker; @@ -47,8 +50,8 @@ class PixelActionTest extends TestCase $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - $this->assertInstanceOf(PixelResponse::class, $response); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('image/gif', $response->getHeaderLine('content-type')); + self::assertInstanceOf(PixelResponse::class, $response); + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals('image/gif', $response->getHeaderLine('content-type')); } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index eb68f0e1..1237585c 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequest; use Mezzio\Router\RouterInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Common\Response\QrCodeResponse; @@ -20,6 +21,8 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; class QrCodeActionTest extends TestCase { + use ProphecyTrait; + private QrCodeAction $action; private ObjectProphecy $urlResolver; @@ -77,8 +80,34 @@ class QrCodeActionTest extends TestCase $delegate->reveal(), ); - $this->assertInstanceOf(QrCodeResponse::class, $resp); - $this->assertEquals(200, $resp->getStatusCode()); + self::assertInstanceOf(QrCodeResponse::class, $resp); + self::assertEquals(200, $resp->getStatusCode()); $delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0); } + + /** + * @test + * @dataProvider provideQueries + */ + public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat( + array $query, + string $expectedContentType + ): void { + $code = 'abc123'; + $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl('')); + $delegate = $this->prophesize(RequestHandlerInterface::class); + $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); + + $resp = $this->action->process($req, $delegate->reveal()); + + self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); + } + + public function provideQueries(): iterable + { + yield 'no format' => [[], 'image/png']; + yield 'png format' => [['format' => 'png'], 'image/png']; + yield 'svg format' => [['format' => 'svg'], 'image/svg+xml']; + yield 'unsupported format' => [['format' => 'jpg'], 'image/png']; + } } diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index 1a6bb617..c4785cf1 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -10,6 +10,7 @@ use Laminas\Diactoros\ServerRequest; use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; @@ -24,6 +25,8 @@ use function array_key_exists; class RedirectActionTest extends TestCase { + use ProphecyTrait; + private RedirectAction $action; private ObjectProphecy $urlResolver; private ObjectProphecy $visitTracker; @@ -60,10 +63,10 @@ class RedirectActionTest extends TestCase $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - $this->assertInstanceOf(Response\RedirectResponse::class, $response); - $this->assertEquals(302, $response->getStatusCode()); - $this->assertTrue($response->hasHeader('Location')); - $this->assertEquals($expectedUrl, $response->getHeaderLine('Location')); + self::assertInstanceOf(Response\RedirectResponse::class, $response); + self::assertEquals(302, $response->getStatusCode()); + self::assertTrue($response->hasHeader('Location')); + self::assertEquals($expectedUrl, $response->getHeaderLine('Location')); $shortCodeToUrl->shouldHaveBeenCalledOnce(); $track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1); } @@ -135,10 +138,10 @@ class RedirectActionTest extends TestCase $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal()); - $this->assertInstanceOf(Response\RedirectResponse::class, $response); - $this->assertEquals($expectedStatus, $response->getStatusCode()); - $this->assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null); - $this->assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? ''); + self::assertInstanceOf(Response\RedirectResponse::class, $response); + self::assertEquals($expectedStatus, $response->getStatusCode()); + self::assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null); + self::assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? ''); } public function provideRedirectConfigs(): iterable diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index 0e08fa89..e0949514 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -32,9 +32,9 @@ class BasePathPrefixerTest extends TestCase 'url_shortener' => $urlShortener, ] = ($this->prefixer)($originalConfig); - $this->assertEquals($expectedRoutes, $routes); - $this->assertEquals($expectedMiddlewares, $middlewares); - $this->assertEquals([ + self::assertEquals($expectedRoutes, $routes); + self::assertEquals($expectedMiddlewares, $middlewares); + self::assertEquals([ 'domain' => [ 'hostname' => $expectedHostname, ], diff --git a/module/Core/test/Config/DeprecatedConfigParserTest.php b/module/Core/test/Config/DeprecatedConfigParserTest.php index 0da1d314..c58d9050 100644 --- a/module/Core/test/Config/DeprecatedConfigParserTest.php +++ b/module/Core/test/Config/DeprecatedConfigParserTest.php @@ -29,7 +29,7 @@ class DeprecatedConfigParserTest extends TestCase $result = ($this->postProcessor)($config); - $this->assertEquals($config, $result); + self::assertEquals($config, $result); } /** @test */ @@ -46,7 +46,7 @@ class DeprecatedConfigParserTest extends TestCase $result = ($this->postProcessor)($config); - $this->assertEquals($config, $result); + self::assertEquals($config, $result); } /** @test */ @@ -68,7 +68,7 @@ class DeprecatedConfigParserTest extends TestCase $result = ($this->postProcessor)($config); - $this->assertEquals($expected, $result); + self::assertEquals($expected, $result); } /** @test */ @@ -89,7 +89,7 @@ class DeprecatedConfigParserTest extends TestCase $result = ($this->postProcessor)($config); - $this->assertEquals($expected, $result); + self::assertEquals($expected, $result); } /** @test */ @@ -106,6 +106,6 @@ class DeprecatedConfigParserTest extends TestCase $result = ($this->postProcessor)($config); - $this->assertEquals($expected, $result); + self::assertEquals($expected, $result); } } diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php index 9d4bca69..6f040bb6 100644 --- a/module/Core/test/Config/SimplifiedConfigParserTest.php +++ b/module/Core/test/Config/SimplifiedConfigParserTest.php @@ -67,6 +67,7 @@ class SimplifiedConfigParserTest extends TestCase 'anonymize_remote_addr' => false, 'redirect_status_code' => 301, 'redirect_cache_lifetime' => 90, + 'port' => 8888, ]; $expected = [ 'app_options' => [ @@ -132,6 +133,7 @@ class SimplifiedConfigParserTest extends TestCase 'mezzio-swoole' => [ 'swoole-http-server' => [ + 'port' => 8888, 'options' => [ 'task_worker_num' => 50, ], @@ -151,6 +153,6 @@ class SimplifiedConfigParserTest extends TestCase $result = ($this->postProcessor)(array_merge($config, $simplified)); - $this->assertEquals(array_merge($expected, $simplified), $result); + self::assertEquals(array_merge($expected, $simplified), $result); } } diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index 71cd90d1..2660803b 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -21,9 +21,9 @@ class ConfigProviderTest extends TestCase { $config = $this->configProvider->__invoke(); - $this->assertArrayHasKey('routes', $config); - $this->assertArrayHasKey('dependencies', $config); - $this->assertArrayHasKey('templates', $config); - $this->assertArrayHasKey('mezzio', $config); + self::assertArrayHasKey('routes', $config); + self::assertArrayHasKey('dependencies', $config); + self::assertArrayHasKey('templates', $config); + self::assertArrayHasKey('mezzio', $config); } } diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php new file mode 100644 index 00000000..906088ea --- /dev/null +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -0,0 +1,52 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->domainService = new DomainService($this->em->reveal()); + } + + /** + * @test + * @dataProvider provideExcludedDomains + */ + public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void + { + $repo = $this->prophesize(DomainRepositoryInterface::class); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + $findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult); + + $result = $this->domainService->listDomainsWithout($excludedDomain); + + self::assertEquals($expectedResult, $result); + $getRepo->shouldHaveBeenCalledOnce(); + $findDomains->shouldHaveBeenCalledOnce(); + } + + public function provideExcludedDomains(): iterable + { + yield 'no excluded domain' => [null, []]; + yield 'foo.com excluded domain' => ['foo.com', []]; + yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]]; + yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]]; + } +} diff --git a/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php b/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php deleted file mode 100644 index d3769af9..00000000 --- a/module/Core/test/Domain/Resolver/PersistenceDomainResolverTest.php +++ /dev/null @@ -1,62 +0,0 @@ -em = $this->prophesize(EntityManagerInterface::class); - $this->domainResolver = new PersistenceDomainResolver($this->em->reveal()); - } - - /** @test */ - public function returnsEmptyWhenNoDomainIsProvided(): void - { - $getRepository = $this->em->getRepository(Domain::class); - - $this->assertNull($this->domainResolver->resolveDomain(null)); - $getRepository->shouldNotHaveBeenCalled(); - } - - /** - * @test - * @dataProvider provideFoundDomains - */ - public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void - { - $repo = $this->prophesize(ObjectRepository::class); - $findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); - $getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); - - $result = $this->domainResolver->resolveDomain($authority); - - if ($foundDomain !== null) { - $this->assertSame($result, $foundDomain); - } - $this->assertInstanceOf(Domain::class, $result); - $this->assertEquals($authority, $result->getAuthority()); - $findDomain->shouldHaveBeenCalledOnce(); - $getRepository->shouldHaveBeenCalledOnce(); - } - - public function provideFoundDomains(): iterable - { - $authority = 'doma.in'; - - yield 'without found domain' => [null, $authority]; - yield 'with found domain' => [new Domain($authority), $authority]; - } -} diff --git a/module/Core/test/Domain/Resolver/SimpleDomainResolverTest.php b/module/Core/test/Domain/Resolver/SimpleDomainResolverTest.php deleted file mode 100644 index a0fa4bf1..00000000 --- a/module/Core/test/Domain/Resolver/SimpleDomainResolverTest.php +++ /dev/null @@ -1,41 +0,0 @@ -domainResolver = new SimpleDomainResolver(); - } - - /** - * @test - * @dataProvider provideDomains - */ - public function resolvesExpectedDomain(?string $domain): void - { - $result = $this->domainResolver->resolveDomain($domain); - - if ($domain === null) { - $this->assertNull($result); - } else { - $this->assertInstanceOf(Domain::class, $result); - $this->assertEquals($domain, $result->getAuthority()); - } - } - - public function provideDomains(): iterable - { - yield 'with empty domain' => [null]; - yield 'with non-empty domain' => ['domain.com']; - } -} diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index 07308580..9f28c41b 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function Functional\map; use function range; @@ -45,16 +46,26 @@ class ShortUrlTest extends TestCase ]; } - /** @test */ - public function regenerateShortCodeProperlyChangesTheValueOnValidShortUrls(): void + /** + * @test + * @dataProvider provideValidShortUrls + */ + public function regenerateShortCodeProperlyChangesTheValueOnValidShortUrls(ShortUrl $shortUrl): void { - $shortUrl = new ShortUrl(''); $firstShortCode = $shortUrl->getShortCode(); $shortUrl->regenerateShortCode(); $secondShortCode = $shortUrl->getShortCode(); - $this->assertNotEquals($firstShortCode, $secondShortCode); + self::assertNotEquals($firstShortCode, $secondShortCode); + } + + public function provideValidShortUrls(): iterable + { + yield 'no custom slug' => [new ShortUrl('')]; + yield 'imported with custom slug' => [ + ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug'), true), + ]; } /** @@ -67,7 +78,7 @@ class ShortUrlTest extends TestCase [ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length], )); - $this->assertEquals($expectedLength, strlen($shortUrl->getShortCode())); + self::assertEquals($expectedLength, strlen($shortUrl->getShortCode())); } public function provideLengths(): iterable @@ -75,38 +86,4 @@ class ShortUrlTest extends TestCase yield [null, DEFAULT_SHORT_CODES_LENGTH]; yield from map(range(4, 10), fn (int $value) => [$value, $value]); } - - /** - * @test - * @dataProvider provideCriteriaToMatch - */ - public function criteriaIsMatchedWhenDatesMatch(ShortUrl $shortUrl, ShortUrlMeta $meta, bool $expected): void - { - $this->assertEquals($expected, $shortUrl->matchesCriteria($meta, [])); - } - - public function provideCriteriaToMatch(): iterable - { - $start = Chronos::parse('2020-03-05 20:18:30'); - $end = Chronos::parse('2021-03-05 20:18:30'); - - yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validSince' => $start]), false]; - yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validUntil' => $end]), false]; - yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end]), false]; - yield [ - new ShortUrl('foo', ShortUrlMeta::fromRawData(['validSince' => $start])), - ShortUrlMeta::fromRawData(['validSince' => $start]), - true, - ]; - yield [ - new ShortUrl('foo', ShortUrlMeta::fromRawData(['validUntil' => $end])), - ShortUrlMeta::fromRawData(['validUntil' => $end]), - true, - ]; - yield [ - new ShortUrl('foo', ShortUrlMeta::fromRawData(['validUntil' => $end, 'validSince' => $start])), - ShortUrlMeta::fromRawData(['validUntil' => $end, 'validSince' => $start]), - true, - ]; - } } diff --git a/module/Core/test/Entity/TagTest.php b/module/Core/test/Entity/TagTest.php index 01b2f6ea..dcc21e71 100644 --- a/module/Core/test/Entity/TagTest.php +++ b/module/Core/test/Entity/TagTest.php @@ -13,6 +13,6 @@ class TagTest extends TestCase public function jsonSerializationOfTagsReturnsItsStringRepresentation(): void { $tag = new Tag('This is my name'); - $this->assertEquals((string) $tag, $tag->jsonSerialize()); + self::assertEquals((string) $tag, $tag->jsonSerialize()); } } diff --git a/module/Core/test/Entity/VisitLocationTest.php b/module/Core/test/Entity/VisitLocationTest.php index f662645d..057a1920 100644 --- a/module/Core/test/Entity/VisitLocationTest.php +++ b/module/Core/test/Entity/VisitLocationTest.php @@ -19,7 +19,7 @@ class VisitLocationTest extends TestCase $payload = new Location(...$args); $location = new VisitLocation($payload); - $this->assertEquals($isEmpty, $location->isEmpty()); + self::assertEquals($isEmpty, $location->isEmpty()); } public function provideArgs(): iterable diff --git a/module/Core/test/Entity/VisitTest.php b/module/Core/test/Entity/VisitTest.php index 73e41f12..9d75f793 100644 --- a/module/Core/test/Entity/VisitTest.php +++ b/module/Core/test/Entity/VisitTest.php @@ -21,7 +21,7 @@ class VisitTest extends TestCase { $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date); - $this->assertEquals([ + self::assertEquals([ 'referer' => 'some site', 'date' => ($date ?? $visit->getDate())->toAtomString(), 'userAgent' => 'Chrome', @@ -43,7 +43,7 @@ class VisitTest extends TestCase { $visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize); - $this->assertEquals($expectedAddress, $visit->getRemoteAddr()); + self::assertEquals($expectedAddress, $visit->getRemoteAddr()); } public function provideAddresses(): iterable diff --git a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php index 61288e02..69cf6c98 100644 --- a/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundRedirectHandlerTest.php @@ -10,6 +10,7 @@ use Laminas\Diactoros\Uri; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -19,6 +20,8 @@ use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; class NotFoundRedirectHandlerTest extends TestCase { + use ProphecyTrait; + private NotFoundRedirectHandler $middleware; private NotFoundRedirectOptions $redirectOptions; @@ -45,8 +48,8 @@ class NotFoundRedirectHandlerTest extends TestCase $resp = $this->middleware->process($request, $next->reveal()); - $this->assertInstanceOf(Response\RedirectResponse::class, $resp); - $this->assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location')); + self::assertInstanceOf(Response\RedirectResponse::class, $resp); + self::assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location')); $handle->shouldNotHaveBeenCalled(); } @@ -93,7 +96,7 @@ class NotFoundRedirectHandlerTest extends TestCase $result = $this->middleware->process($req, $next->reveal()); - $this->assertSame($resp, $result); + self::assertSame($resp, $result); $handle->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php index e43b3aab..e10954ca 100644 --- a/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php +++ b/module/Core/test/ErrorHandler/NotFoundTemplateHandlerTest.php @@ -10,6 +10,7 @@ use Mezzio\Router\Route; use Mezzio\Router\RouteResult; use Mezzio\Template\TemplateRendererInterface; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -17,6 +18,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler; class NotFoundTemplateHandlerTest extends TestCase { + use ProphecyTrait; + private NotFoundTemplateHandler $handler; private ObjectProphecy $renderer; @@ -37,7 +40,7 @@ class NotFoundTemplateHandlerTest extends TestCase $resp = $this->handler->handle($request); - $this->assertInstanceOf(Response\HtmlResponse::class, $resp); + self::assertInstanceOf(Response\HtmlResponse::class, $resp); $render->shouldHaveBeenCalledOnce(); } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php index 60113fc7..c928200e 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerDelegatorTest.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Rest\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface; @@ -12,6 +13,8 @@ use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListenerDelegator class CloseDbConnectionEventListenerDelegatorTest extends TestCase { + use ProphecyTrait; + private CloseDbConnectionEventListenerDelegator $delegator; private ObjectProphecy $container; @@ -37,7 +40,7 @@ class CloseDbConnectionEventListenerDelegatorTest extends TestCase ($this->delegator)($this->container->reveal(), '', $callback); - $this->assertTrue($callbackInvoked); + self::assertTrue($callbackInvoked); $getEm->shouldHaveBeenCalledOnce(); } } diff --git a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php index acc1784f..5f08e5fe 100644 --- a/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php +++ b/module/Core/test/EventDispatcher/CloseDbConnectionEventListenerTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\DBAL\Connection; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface; @@ -15,6 +16,8 @@ use Throwable; class CloseDbConnectionEventListenerTest extends TestCase { + use ProphecyTrait; + private ObjectProphecy $em; public function setUp(): void @@ -45,7 +48,7 @@ class CloseDbConnectionEventListenerTest extends TestCase // Ignore exceptions } - $this->assertTrue($wrappedWasCalled); + self::assertTrue($wrappedWasCalled); $close->shouldHaveBeenCalledOnce(); $getConn->shouldHaveBeenCalledOnce(); $clear->shouldHaveBeenCalledOnce(); diff --git a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php index 087c0e0b..ab12a349 100644 --- a/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateShortUrlVisitTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -26,6 +27,8 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; class LocateShortUrlVisitTest extends TestCase { + use ProphecyTrait; + private LocateShortUrlVisit $locateVisit; private ObjectProphecy $ipLocationResolver; private ObjectProphecy $em; @@ -112,7 +115,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); - $this->assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance())); + self::assertEquals($visit->getVisitLocation(), new VisitLocation(Location::emptyInstance())); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldNotHaveBeenCalled(); @@ -149,7 +152,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); - $this->assertEquals($visit->getVisitLocation(), new VisitLocation($location)); + self::assertEquals($visit->getVisitLocation(), new VisitLocation($location)); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); @@ -182,7 +185,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); - $this->assertEquals($visit->getVisitLocation(), new VisitLocation($location)); + self::assertEquals($visit->getVisitLocation(), new VisitLocation($location)); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); $resolveIp->shouldHaveBeenCalledOnce(); @@ -217,7 +220,7 @@ class LocateShortUrlVisitTest extends TestCase ($this->locateVisit)($event); - $this->assertNull($visit->getVisitLocation()); + self::assertNull($visit->getVisitLocation()); $findVisit->shouldHaveBeenCalledOnce(); $flush->shouldNotHaveBeenCalled(); $resolveIp->shouldNotHaveBeenCalled(); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php index fce53344..90891db3 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; @@ -21,6 +22,8 @@ use Symfony\Component\Mercure\Update; class NotifyVisitToMercureTest extends TestCase { + use ProphecyTrait; + private NotifyVisitToMercure $listener; private ObjectProphecy $publisher; private ObjectProphecy $updatesGenerator; diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php index 7a138960..8319f448 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php @@ -14,6 +14,7 @@ use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -28,6 +29,8 @@ use function Functional\contains; class NotifyVisitToWebHooksTest extends TestCase { + use ProphecyTrait; + private ObjectProphecy $httpClient; private ObjectProphecy $em; private ObjectProphecy $logger; diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php index 6e45521f..a7028a02 100644 --- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php +++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php @@ -25,16 +25,16 @@ class DeleteShortUrlExceptionTest extends TestCase ): void { $e = DeleteShortUrlException::fromVisitsThreshold($threshold, $shortCode); - $this->assertEquals($threshold, $e->getVisitsThreshold()); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals([ + self::assertEquals($threshold, $e->getVisitsThreshold()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals([ 'shortCode' => $shortCode, 'threshold' => $threshold, ], $e->getAdditionalData()); - $this->assertEquals('Cannot delete short URL', $e->getTitle()); - $this->assertEquals('INVALID_SHORTCODE_DELETION', $e->getType()); - $this->assertEquals(422, $e->getStatus()); + self::assertEquals('Cannot delete short URL', $e->getTitle()); + self::assertEquals('INVALID_SHORTCODE_DELETION', $e->getType()); + self::assertEquals(422, $e->getStatus()); } public function provideThresholds(): array diff --git a/module/Core/test/Exception/InvalidUrlExceptionTest.php b/module/Core/test/Exception/InvalidUrlExceptionTest.php index cb0a08bc..5351c1b3 100644 --- a/module/Core/test/Exception/InvalidUrlExceptionTest.php +++ b/module/Core/test/Exception/InvalidUrlExceptionTest.php @@ -24,14 +24,14 @@ class InvalidUrlExceptionTest extends TestCase $expectedMessage = sprintf('Provided URL %s is invalid. Try with a different one.', $url); $e = InvalidUrlException::fromUrl($url, $prev); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Invalid URL', $e->getTitle()); - $this->assertEquals('INVALID_URL', $e->getType()); - $this->assertEquals(['url' => $url], $e->getAdditionalData()); - $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); - $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); - $this->assertEquals($prev, $e->getPrevious()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Invalid URL', $e->getTitle()); + self::assertEquals('INVALID_URL', $e->getType()); + self::assertEquals(['url' => $url], $e->getAdditionalData()); + self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); + self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); + self::assertEquals($prev, $e->getPrevious()); } public function providePrevious(): iterable diff --git a/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php b/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php index 84ee433c..b1487b69 100644 --- a/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php +++ b/module/Core/test/Exception/IpCannotBeLocatedExceptionTest.php @@ -18,10 +18,10 @@ class IpCannotBeLocatedExceptionTest extends TestCase { $e = IpCannotBeLocatedException::forEmptyAddress(); - $this->assertTrue($e->isNonLocatableAddress()); - $this->assertEquals('Ignored visit with no IP address', $e->getMessage()); - $this->assertEquals(0, $e->getCode()); - $this->assertNull($e->getPrevious()); + self::assertTrue($e->isNonLocatableAddress()); + self::assertEquals('Ignored visit with no IP address', $e->getMessage()); + self::assertEquals(0, $e->getCode()); + self::assertNull($e->getPrevious()); } /** @test */ @@ -29,10 +29,10 @@ class IpCannotBeLocatedExceptionTest extends TestCase { $e = IpCannotBeLocatedException::forLocalhost(); - $this->assertTrue($e->isNonLocatableAddress()); - $this->assertEquals('Ignored localhost address', $e->getMessage()); - $this->assertEquals(0, $e->getCode()); - $this->assertNull($e->getPrevious()); + self::assertTrue($e->isNonLocatableAddress()); + self::assertEquals('Ignored localhost address', $e->getMessage()); + self::assertEquals(0, $e->getCode()); + self::assertNull($e->getPrevious()); } /** @@ -43,10 +43,10 @@ class IpCannotBeLocatedExceptionTest extends TestCase { $e = IpCannotBeLocatedException::forError($prev); - $this->assertFalse($e->isNonLocatableAddress()); - $this->assertEquals('An error occurred while locating IP', $e->getMessage()); - $this->assertEquals($prev->getCode(), $e->getCode()); - $this->assertSame($prev, $e->getPrevious()); + self::assertFalse($e->isNonLocatableAddress()); + self::assertEquals('An error occurred while locating IP', $e->getMessage()); + self::assertEquals($prev->getCode(), $e->getCode()); + self::assertSame($prev, $e->getPrevious()); } public function provideErrors(): iterable diff --git a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php index 00efa3cf..6720f0f3 100644 --- a/module/Core/test/Exception/NonUniqueSlugExceptionTest.php +++ b/module/Core/test/Exception/NonUniqueSlugExceptionTest.php @@ -22,12 +22,12 @@ class NonUniqueSlugExceptionTest extends TestCase $e = NonUniqueSlugException::fromSlug($slug, $domain); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Invalid custom slug', $e->getTitle()); - $this->assertEquals('INVALID_SLUG', $e->getType()); - $this->assertEquals(400, $e->getStatus()); - $this->assertEquals($expectedAdditional, $e->getAdditionalData()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Invalid custom slug', $e->getTitle()); + self::assertEquals('INVALID_SLUG', $e->getType()); + self::assertEquals(400, $e->getStatus()); + self::assertEquals($expectedAdditional, $e->getAdditionalData()); } public function provideMessages(): iterable diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index d0d77fb8..e6a48914 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -26,12 +26,12 @@ class ShortUrlNotFoundExceptionTest extends TestCase $e = ShortUrlNotFoundException::fromNotFound(new ShortUrlIdentifier($shortCode, $domain)); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Short URL not found', $e->getTitle()); - $this->assertEquals('INVALID_SHORTCODE', $e->getType()); - $this->assertEquals(404, $e->getStatus()); - $this->assertEquals($expectedAdditional, $e->getAdditionalData()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Short URL not found', $e->getTitle()); + self::assertEquals('INVALID_SHORTCODE', $e->getType()); + self::assertEquals(404, $e->getStatus()); + self::assertEquals($expectedAdditional, $e->getAdditionalData()); } public function provideMessages(): iterable diff --git a/module/Core/test/Exception/TagConflictExceptionTest.php b/module/Core/test/Exception/TagConflictExceptionTest.php index f09e3a32..156fd500 100644 --- a/module/Core/test/Exception/TagConflictExceptionTest.php +++ b/module/Core/test/Exception/TagConflictExceptionTest.php @@ -19,11 +19,11 @@ class TagConflictExceptionTest extends TestCase $expectedMessage = sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName); $e = TagConflictException::fromExistingTag($oldName, $newName); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Tag conflict', $e->getTitle()); - $this->assertEquals('TAG_CONFLICT', $e->getType()); - $this->assertEquals(['oldName' => $oldName, 'newName' => $newName], $e->getAdditionalData()); - $this->assertEquals(409, $e->getStatus()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Tag conflict', $e->getTitle()); + self::assertEquals('TAG_CONFLICT', $e->getType()); + self::assertEquals(['oldName' => $oldName, 'newName' => $newName], $e->getAdditionalData()); + self::assertEquals(409, $e->getStatus()); } } diff --git a/module/Core/test/Exception/TagNotFoundExceptionTest.php b/module/Core/test/Exception/TagNotFoundExceptionTest.php index ccee7b38..c6e8bf1d 100644 --- a/module/Core/test/Exception/TagNotFoundExceptionTest.php +++ b/module/Core/test/Exception/TagNotFoundExceptionTest.php @@ -18,11 +18,11 @@ class TagNotFoundExceptionTest extends TestCase $expectedMessage = sprintf('Tag with name "%s" could not be found', $tag); $e = TagNotFoundException::fromTag($tag); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Tag not found', $e->getTitle()); - $this->assertEquals('TAG_NOT_FOUND', $e->getType()); - $this->assertEquals(['tag' => $tag], $e->getAdditionalData()); - $this->assertEquals(404, $e->getStatus()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Tag not found', $e->getTitle()); + self::assertEquals('TAG_NOT_FOUND', $e->getType()); + self::assertEquals(['tag' => $tag], $e->getAdditionalData()); + self::assertEquals(404, $e->getStatus()); } } diff --git a/module/Core/test/Exception/ValidationExceptionTest.php b/module/Core/test/Exception/ValidationExceptionTest.php index 780b25e3..44a46c1f 100644 --- a/module/Core/test/Exception/ValidationExceptionTest.php +++ b/module/Core/test/Exception/ValidationExceptionTest.php @@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\InputFilter\InputFilterInterface; use LogicException; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use RuntimeException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Throwable; @@ -17,6 +18,8 @@ use function print_r; class ValidationExceptionTest extends TestCase { + use ProphecyTrait; + /** * @test * @dataProvider provideExceptions @@ -38,12 +41,12 @@ EOT; $e = ValidationException::fromInputFilter($inputFilter->reveal()); - $this->assertEquals($invalidData, $e->getInvalidElements()); - $this->assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData()); - $this->assertEquals('Provided data is not valid', $e->getMessage()); - $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); - $this->assertEquals($prev, $e->getPrevious()); - $this->assertStringContainsString($expectedStringRepresentation, (string) $e); + self::assertEquals($invalidData, $e->getInvalidElements()); + self::assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData()); + self::assertEquals('Provided data is not valid', $e->getMessage()); + self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); + self::assertEquals($prev, $e->getPrevious()); + self::assertStringContainsString($expectedStringRepresentation, (string) $e); $getMessages->shouldHaveBeenCalledOnce(); } diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php new file mode 100644 index 00000000..174e9afc --- /dev/null +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -0,0 +1,148 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->repo = $this->prophesize(ShortUrlRepositoryInterface::class); + $this->em->getRepository(ShortUrl::class)->willReturn($this->repo->reveal()); + + $this->shortCodeHelper = $this->prophesize(ShortCodeHelperInterface::class); + $batchHelper = $this->prophesize(DoctrineBatchHelperInterface::class); + $batchHelper->wrapIterable(Argument::cetera())->willReturnArgument(0); + + $this->processor = new ImportedLinksProcessor( + $this->em->reveal(), + new SimpleShortUrlRelationResolver(), + $this->shortCodeHelper->reveal(), + $batchHelper->reveal(), + ); + + $this->io = $this->prophesize(StyleInterface::class); + } + + /** @test */ + public function newUrlsWithNoErrorsAreAllPersisted(): void + { + $urls = [ + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), + ]; + $expectedCalls = count($urls); + + $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $persist = $this->em->persist(Argument::type(ShortUrl::class)); + + $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + + $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls); + $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); + $persist->shouldHaveBeenCalledTimes($expectedCalls); + $this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls); + } + + /** @test */ + public function alreadyImportedUrlsAreSkipped(): void + { + $urls = [ + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), + new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'), + new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'), + ]; + $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); + + $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->will(function (array $args): bool { + /** @var ImportedShlinkUrl $url */ + [$url] = $args; + + return contains(['foo', 'baz2', 'baz3'], $url->longUrl()); + }); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $persist = $this->em->persist(Argument::type(ShortUrl::class)); + + $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + + $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); + $ensureUniqueness->shouldHaveBeenCalledTimes(2); + $persist->shouldHaveBeenCalledTimes(2); + $this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2); + } + + /** @test */ + public function nonUniqueShortCodesAreAskedToUser(): void + { + $urls = [ + new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo'), + new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar'), + new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz'), + new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2'), + new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3'), + ]; + $contains = fn (string $needle) => fn (string $text) => str_contains($text, $needle); + + $importedUrlExists = $this->repo->importedUrlExists(Argument::cetera())->willReturn(false); + $failingEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness( + Argument::any(), + true, + )->willReturn(false); + $successEnsureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness( + Argument::any(), + false, + )->willReturn(true); + $choice = $this->io->choice(Argument::cetera())->will(function (array $args) { + /** @var ImportedShlinkUrl $url */ + [$question] = $args; + + return some(['foo', 'baz2', 'baz3'], fn (string $item) => str_contains($question, $item)) ? 'Skip' : ''; + }); + $persist = $this->em->persist(Argument::type(ShortUrl::class)); + + $this->processor->process($this->io->reveal(), $urls, ['import_short_codes' => true]); + + $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); + $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); + $successEnsureUniqueness->shouldHaveBeenCalledTimes(2); + $choice->shouldHaveBeenCalledTimes(5); + $persist->shouldHaveBeenCalledTimes(2); + $this->io->text(Argument::that($contains('Skipped')))->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::that($contains('Imported')))->shouldHaveBeenCalledTimes(2); + } +} diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index 992e25d6..aef2a489 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -33,8 +33,8 @@ class MercureUpdatesGeneratorTest extends TestCase $update = $this->generator->{$method}($visit); - $this->assertEquals([$expectedTopic], $update->getTopics()); - $this->assertEquals([ + self::assertEquals([$expectedTopic], $update->getTopics()); + self::assertEquals([ 'shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index fe3d42fc..c8cc3ff6 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -68,17 +68,17 @@ class ShortUrlMetaTest extends TestCase ['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug], ); - $this->assertTrue($meta->hasValidSince()); - $this->assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); + self::assertTrue($meta->hasValidSince()); + self::assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince()); - $this->assertFalse($meta->hasValidUntil()); - $this->assertNull($meta->getValidUntil()); + self::assertFalse($meta->hasValidUntil()); + self::assertNull($meta->getValidUntil()); - $this->assertTrue($meta->hasCustomSlug()); - $this->assertEquals($expectedSlug, $meta->getCustomSlug()); + self::assertTrue($meta->hasCustomSlug()); + self::assertEquals($expectedSlug, $meta->getCustomSlug()); - $this->assertFalse($meta->hasMaxVisits()); - $this->assertNull($meta->getMaxVisits()); + self::assertFalse($meta->hasMaxVisits()); + self::assertNull($meta->getMaxVisits()); } public function provideCustomSlugs(): iterable diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index 0a0c1828..d52a6389 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -23,9 +23,9 @@ class VisitorTest extends TestCase $visitor = new Visitor(...$params); ['userAgent' => $userAgent, 'referer' => $referer, 'remoteAddress' => $remoteAddress] = $expected; - $this->assertEquals($userAgent, $visitor->getUserAgent()); - $this->assertEquals($referer, $visitor->getReferer()); - $this->assertEquals($remoteAddress, $visitor->getRemoteAddress()); + self::assertEquals($userAgent, $visitor->getUserAgent()); + self::assertEquals($referer, $visitor->getReferer()); + self::assertEquals($remoteAddress, $visitor->getRemoteAddress()); } public function provideParams(): iterable diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index eb094c25..9f541ebe 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Paginator\Adapter; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter; @@ -13,6 +14,8 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; class ShortUrlRepositoryAdapterTest extends TestCase { + use ProphecyTrait; + private ObjectProphecy $repo; public function setUp(): void diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index e4418c5b..b3a47749 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Paginator\Adapter; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\VisitsParams; @@ -13,6 +14,8 @@ use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; class VisitsForTagPaginatorAdapterTest extends TestCase { + use ProphecyTrait; + private VisitsForTagPaginatorAdapter $adapter; private ObjectProphecy $repo; diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 744582b7..508a0984 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Paginator\Adapter; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -14,6 +15,8 @@ use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; class VisitsPaginatorAdapterTest extends TestCase { + use ProphecyTrait; + private VisitsPaginatorAdapter $adapter; private ObjectProphecy $repo; diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 0911cb7b..449220b4 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; @@ -24,6 +25,8 @@ use function sprintf; class DeleteShortUrlServiceTest extends TestCase { + use ProphecyTrait; + private ObjectProphecy $em; private ObjectProphecy $urlResolver; private string $shortCode; diff --git a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php new file mode 100644 index 00000000..047dbc96 --- /dev/null +++ b/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php @@ -0,0 +1,80 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->helper = new ShortCodeHelper($this->em->reveal()); + + $this->shortUrl = $this->prophesize(ShortUrl::class); + $this->shortUrl->getShortCode()->willReturn('abc123'); + } + + /** + * @test + * @dataProvider provideDomains + */ + public function shortCodeIsRegeneratedIfAlreadyInUse(?Domain $domain, ?string $expectedAuthority): void + { + $callIndex = 0; + $expectedCalls = 3; + $repo = $this->prophesize(ShortUrlRepository::class); + $shortCodeIsInUse = $repo->shortCodeIsInUse('abc123', $expectedAuthority)->will( + function () use (&$callIndex, $expectedCalls) { + $callIndex++; + return $callIndex < $expectedCalls; + }, + ); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->shortUrl->getDomain()->willReturn($domain); + + $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl->reveal(), false); + + self::assertTrue($result); + $this->shortUrl->regenerateShortCode()->shouldHaveBeenCalledTimes($expectedCalls - 1); + $getRepo->shouldBeCalledTimes($expectedCalls); + $shortCodeIsInUse->shouldBeCalledTimes($expectedCalls); + } + + public function provideDomains(): iterable + { + yield 'no domain' => [null, null]; + yield 'domain' => [new Domain($authority = 'doma.in'), $authority]; + } + + /** @test */ + public function inUseSlugReturnsError(): void + { + $repo = $this->prophesize(ShortUrlRepository::class); + $shortCodeIsInUse = $repo->shortCodeIsInUse('abc123', null)->willReturn(true); + $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->shortUrl->getDomain()->willReturn(null); + + $result = $this->helper->ensureShortCodeUniqueness($this->shortUrl->reveal(), true); + + self::assertFalse($result); + $this->shortUrl->regenerateShortCode()->shouldNotHaveBeenCalled(); + $getRepo->shouldBeCalledOnce(); + $shortCodeIsInUse->shouldBeCalledOnce(); + } +} diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 5b3e3e19..3566b285 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; @@ -23,6 +24,8 @@ use function range; class ShortUrlResolverTest extends TestCase { + use ProphecyTrait; + private ShortUrlResolver $urlResolver; private ObjectProphecy $em; @@ -44,7 +47,7 @@ class ShortUrlResolverTest extends TestCase $result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode)); - $this->assertSame($shortUrl, $result); + self::assertSame($shortUrl, $result); $findOne->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } @@ -77,7 +80,7 @@ class ShortUrlResolverTest extends TestCase $result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode)); - $this->assertSame($shortUrl, $result); + self::assertSame($shortUrl, $result); $findOneByShortCode->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 9becdf8b..fc2de22b 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; @@ -24,6 +25,8 @@ use function count; class ShortUrlServiceTest extends TestCase { + use ProphecyTrait; + private ShortUrlService $service; private ObjectProphecy $em; private ObjectProphecy $urlResolver; @@ -61,7 +64,7 @@ class ShortUrlServiceTest extends TestCase $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance()); - $this->assertEquals(4, $list->getCurrentItemCount()); + self::assertEquals(4, $list->getCurrentItemCount()); } /** @test */ @@ -97,14 +100,17 @@ class ShortUrlServiceTest extends TestCase $result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), $shortUrlEdit); - $this->assertSame($shortUrl, $result); - $this->assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); - $this->assertEquals($shortUrlEdit->validUntil(), $shortUrl->getValidUntil()); - $this->assertEquals($shortUrlEdit->maxVisits(), $shortUrl->getMaxVisits()); - $this->assertEquals($shortUrlEdit->longUrl() ?? $originalLongUrl, $shortUrl->getLongUrl()); + 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()); $findShortUrl->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); - $this->urlValidator->validateUrl($shortUrlEdit->longUrl())->shouldHaveBeenCalledTimes($expectedValidateCalls); + $this->urlValidator->validateUrl( + $shortUrlEdit->longUrl(), + $shortUrlEdit->doValidateUrl(), + )->shouldHaveBeenCalledTimes($expectedValidateCalls); } public function provideShortUrlEdits(): iterable @@ -123,5 +129,11 @@ class ShortUrlServiceTest extends TestCase 'longUrl' => 'modifiedLongUrl', ], )]; + yield 'long URL with validation' => [1, ShortUrlEdit::fromRawData( + [ + 'longUrl' => 'modifiedLongUrl', + 'validateUrl' => true, + ], + )]; } } diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index c031e51f..16fd8683 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Service\Tag; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\TagConflictException; @@ -17,6 +18,8 @@ use Shlinkio\Shlink\Core\Tag\TagService; class TagServiceTest extends TestCase { + use ProphecyTrait; + private TagService $service; private ObjectProphecy $em; private ObjectProphecy $repo; @@ -39,7 +42,7 @@ class TagServiceTest extends TestCase $result = $this->service->listTags(); - $this->assertEquals($expected, $result); + self::assertEquals($expected, $result); $find->shouldHaveBeenCalled(); } @@ -52,7 +55,7 @@ class TagServiceTest extends TestCase $result = $this->service->tagsInfo(); - $this->assertEquals($expected, $result); + self::assertEquals($expected, $result); $find->shouldHaveBeenCalled(); } @@ -75,7 +78,7 @@ class TagServiceTest extends TestCase $result = $this->service->createTags(['foo', 'bar']); - $this->assertCount(2, $result); + self::assertCount(2, $result); $find->shouldHaveBeenCalled(); $persist->shouldHaveBeenCalledTimes(2); $flush->shouldHaveBeenCalled(); @@ -106,8 +109,8 @@ class TagServiceTest extends TestCase $tag = $this->service->renameTag($oldName, $newName); - $this->assertSame($expected, $tag); - $this->assertEquals($newName, (string) $tag); + self::assertSame($expected, $tag); + self::assertEquals($newName, (string) $tag); $find->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled(); $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index f1ef88af..9d8c5273 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -6,130 +6,86 @@ namespace ShlinkioTest\Shlink\Core\Service; use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\ORMException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface; use Shlinkio\Shlink\Core\Service\UrlShortener; +use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; -use function array_map; - class UrlShortenerTest extends TestCase { + use ProphecyTrait; + private UrlShortener $urlShortener; private ObjectProphecy $em; private ObjectProphecy $urlValidator; + private ObjectProphecy $shortCodeHelper; public function setUp(): void { $this->urlValidator = $this->prophesize(UrlValidatorInterface::class); - $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will( + $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar', null)->will( function (): void { }, ); $this->em = $this->prophesize(EntityManagerInterface::class); - $conn = $this->prophesize(Connection::class); - $conn->isTransactionActive()->willReturn(false); - $this->em->getConnection()->willReturn($conn->reveal()); - $this->em->flush()->willReturn(null); - $this->em->commit()->willReturn(null); - $this->em->beginTransaction()->willReturn(null); $this->em->persist(Argument::any())->will(function ($arguments): void { /** @var ShortUrl $shortUrl */ [$shortUrl] = $arguments; $shortUrl->setId('10'); }); + $this->em->transactional(Argument::type('callable'))->will(function (array $args) { + /** @var callable $callback */ + [$callback] = $args; + + return $callback(); + }); $repo = $this->prophesize(ShortUrlRepository::class); $repo->shortCodeIsInUse(Argument::cetera())->willReturn(false); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $this->shortCodeHelper = $this->prophesize(ShortCodeHelperInterface::class); + $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $this->urlShortener = new UrlShortener( $this->urlValidator->reveal(), $this->em->reveal(), - new SimpleDomainResolver(), + new SimpleShortUrlRelationResolver(), + $this->shortCodeHelper->reveal(), ); } /** @test */ public function urlIsProperlyShortened(): void { - $shortUrl = $this->urlShortener->urlToShortCode( + $shortUrl = $this->urlShortener->shorten( 'http://foobar.com/12345/hello?foo=bar', [], ShortUrlMeta::createEmpty(), ); - $this->assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); - } - - /** @test */ - public function shortCodeIsRegeneratedIfAlreadyInUse(): void - { - $callIndex = 0; - $expectedCalls = 3; - $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUse(Argument::cetera())->will( - function () use (&$callIndex, $expectedCalls) { - $callIndex++; - return $callIndex < $expectedCalls; - }, - ); - $repo->findBy(Argument::cetera())->willReturn([]); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - - $shortUrl = $this->urlShortener->urlToShortCode( - 'http://foobar.com/12345/hello?foo=bar', - [], - ShortUrlMeta::createEmpty(), - ); - - $this->assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); - $getRepo->shouldBeCalledTimes($expectedCalls); - $shortCodeIsInUse->shouldBeCalledTimes($expectedCalls); - } - - /** @test */ - public function transactionIsRolledBackAndExceptionRethrownWhenExceptionIsThrown(): void - { - $conn = $this->prophesize(Connection::class); - $conn->isTransactionActive()->willReturn(true); - $this->em->getConnection()->willReturn($conn->reveal()); - $this->em->rollback()->shouldBeCalledOnce(); - $this->em->close()->shouldBeCalledOnce(); - - $this->em->flush()->willThrow(new ORMException()); - - $this->expectException(ORMException::class); - $this->urlShortener->urlToShortCode( - 'http://foobar.com/12345/hello?foo=bar', - [], - ShortUrlMeta::createEmpty(), - ); + self::assertEquals('http://foobar.com/12345/hello?foo=bar', $shortUrl->getLongUrl()); } /** @test */ public function exceptionIsThrownWhenNonUniqueSlugIsProvided(): void { - $repo = $this->prophesize(ShortUrlRepository::class); - $shortCodeIsInUse = $repo->shortCodeIsInUse('custom-slug', null)->willReturn(true); - $repo->findBy(Argument::cetera())->willReturn([]); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(false); - $shortCodeIsInUse->shouldBeCalledOnce(); - $getRepo->shouldBeCalled(); + $ensureUniqueness->shouldBeCalledOnce(); $this->expectException(NonUniqueSlugException::class); - $this->urlShortener->urlToShortCode( + $this->urlShortener->shorten( 'http://foobar.com/12345/hello?foo=bar', [], ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug']), @@ -147,16 +103,16 @@ class UrlShortenerTest extends TestCase ShortUrl $expected ): void { $repo = $this->prophesize(ShortUrlRepository::class); - $findExisting = $repo->findBy(Argument::any())->willReturn([$expected]); + $findExisting = $repo->findOneMatching(Argument::cetera())->willReturn($expected); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlShortener->urlToShortCode($url, $tags, $meta); + $result = $this->urlShortener->shorten($url, $tags, $meta); $findExisting->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); $this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled(); $this->urlValidator->validateUrl(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->assertSame($expected, $result); + self::assertSame($expected, $result); } public function provideExistingShortUrls(): iterable @@ -211,33 +167,4 @@ class UrlShortenerTest extends TestCase ])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])), ]; } - - /** @test */ - public function properExistingShortUrlIsReturnedWhenMultipleMatch(): void - { - $url = 'http://foo.com'; - $tags = ['baz', 'foo', 'bar']; - $meta = ShortUrlMeta::fromRawData([ - 'findIfExists' => true, - 'validUntil' => Chronos::parse('2017-01-01'), - 'maxVisits' => 4, - ]); - $tagsCollection = new ArrayCollection(array_map(fn (string $tag) => new Tag($tag), $tags)); - $expected = (new ShortUrl($url, $meta))->setTags($tagsCollection); - - $repo = $this->prophesize(ShortUrlRepository::class); - $findExisting = $repo->findBy(Argument::any())->willReturn([ - new ShortUrl($url), - new ShortUrl($url, $meta), - $expected, - (new ShortUrl($url))->setTags($tagsCollection), - ]); - $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - - $result = $this->urlShortener->urlToShortCode($url, $tags, $meta); - - $this->assertSame($expected, $result); - $findExisting->shouldHaveBeenCalledOnce(); - $getRepo->shouldHaveBeenCalledOnce(); - } } diff --git a/module/Core/test/Service/VisitsTrackerTest.php b/module/Core/test/Service/VisitsTrackerTest.php index 5893b952..1d9096e3 100644 --- a/module/Core/test/Service/VisitsTrackerTest.php +++ b/module/Core/test/Service/VisitsTrackerTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManager; use Laminas\Stdlib\ArrayUtils; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Common\Util\DateRange; @@ -30,6 +31,8 @@ use function range; class VisitsTrackerTest extends TestCase { + use ProphecyTrait; + private VisitsTracker $visitsTracker; private ObjectProphecy $em; private ObjectProphecy $eventDispatcher; @@ -71,7 +74,7 @@ class VisitsTrackerTest extends TestCase $paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); - $this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); $count->shouldHaveBeenCalledOnce(); } @@ -120,7 +123,7 @@ class VisitsTrackerTest extends TestCase $paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams()); - $this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems())); $count->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php new file mode 100644 index 00000000..5791d579 --- /dev/null +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -0,0 +1,100 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->resolver = new PersistenceShortUrlRelationResolver($this->em->reveal()); + } + + /** @test */ + public function returnsEmptyWhenNoDomainIsProvided(): void + { + $getRepository = $this->em->getRepository(Domain::class); + + self::assertNull($this->resolver->resolveDomain(null)); + $getRepository->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideFoundDomains + */ + public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void + { + $repo = $this->prophesize(ObjectRepository::class); + $findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain); + $getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $result = $this->resolver->resolveDomain($authority); + + if ($foundDomain !== null) { + self::assertSame($result, $foundDomain); + } + self::assertInstanceOf(Domain::class, $result); + self::assertEquals($authority, $result->getAuthority()); + $findDomain->shouldHaveBeenCalledOnce(); + $getRepository->shouldHaveBeenCalledOnce(); + } + + public function provideFoundDomains(): iterable + { + $authority = 'doma.in'; + + yield 'not found domain' => [null, $authority]; + yield 'found domain' => [new Domain($authority), $authority]; + } + + /** @test */ + public function returnsEmptyWhenNoApiKeyIsProvided(): void + { + $getRepository = $this->em->getRepository(ApiKey::class); + + self::assertNull($this->resolver->resolveApiKey(null)); + $getRepository->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideFoundApiKeys + */ + public function triesToFindApiKeyWhenValueIsProvided(?ApiKey $foundApiKey, string $key): void + { + $repo = $this->prophesize(ObjectRepository::class); + $find = $repo->findOneBy(['key' => $key])->willReturn($foundApiKey); + $getRepository = $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); + + $result = $this->resolver->resolveApiKey($key); + + self::assertSame($result, $foundApiKey); + $find->shouldHaveBeenCalledOnce(); + $getRepository->shouldHaveBeenCalledOnce(); + } + + public function provideFoundApiKeys(): iterable + { + $key = 'abc123'; + + yield 'not found api key' => [null, $key]; + yield 'found api key' => [new ApiKey(), $key]; + } +} diff --git a/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php new file mode 100644 index 00000000..e2d0822c --- /dev/null +++ b/module/Core/test/ShortUrl/Resolver/SimpleShortUrlRelationResolverTest.php @@ -0,0 +1,56 @@ +resolver = new SimpleShortUrlRelationResolver(); + } + + /** + * @test + * @dataProvider provideDomains + */ + public function resolvesExpectedDomain(?string $domain): void + { + $result = $this->resolver->resolveDomain($domain); + + if ($domain === null) { + self::assertNull($result); + } else { + self::assertInstanceOf(Domain::class, $result); + self::assertEquals($domain, $result->getAuthority()); + } + } + + public function provideDomains(): iterable + { + yield 'empty domain' => [null]; + yield 'non-empty domain' => ['domain.com']; + } + + /** + * @test + * @dataProvider provideKeys + */ + public function alwaysReturnsNullForApiKeys(?string $key): void + { + self::assertNull($this->resolver->resolveApiKey($key)); + } + + public function provideKeys(): iterable + { + yield 'empty api key' => [null]; + yield 'non-empty api key' => ['abc123']; + } +} diff --git a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php b/module/Core/test/Transformer/ShortUrlDataTransformerTest.php index a65b9506..9abe5f1a 100644 --- a/module/Core/test/Transformer/ShortUrlDataTransformerTest.php +++ b/module/Core/test/Transformer/ShortUrlDataTransformerTest.php @@ -29,7 +29,7 @@ class ShortUrlDataTransformerTest extends TestCase { ['meta' => $meta] = $this->transformer->transform($shortUrl); - $this->assertEquals($expectedMeta, $meta); + self::assertEquals($expectedMeta, $meta); } public function provideShortUrls(): iterable diff --git a/module/Core/test/Util/DoctrineBatchHelperTest.php b/module/Core/test/Util/DoctrineBatchHelperTest.php new file mode 100644 index 00000000..b655c070 --- /dev/null +++ b/module/Core/test/Util/DoctrineBatchHelperTest.php @@ -0,0 +1,73 @@ +em = $this->prophesize(EntityManagerInterface::class); + $this->helper = new DoctrineBatchHelper($this->em->reveal()); + } + + /** + * @test + * @dataProvider provideIterables + */ + public function entityManagerIsFlushedAndClearedTheExpectedAmountOfTimes( + array $iterable, + int $batchSize, + int $expectedCalls + ): void { + $wrappedIterable = $this->helper->wrapIterable($iterable, $batchSize); + + foreach ($wrappedIterable as $item) { + // Iterable needs to be iterated for the logic to be invoked + } + + $this->em->beginTransaction()->shouldHaveBeenCalledOnce(); + $this->em->commit()->shouldHaveBeenCalledOnce(); + $this->em->rollback()->shouldNotHaveBeenCalled(); + $this->em->flush()->shouldHaveBeenCalledTimes($expectedCalls); + $this->em->clear()->shouldHaveBeenCalledTimes($expectedCalls); + } + + public function provideIterables(): iterable + { + yield [[], 100, 1]; + yield [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 3, 4]; + yield [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 11, 1]; + } + + /** @test */ + public function transactionIsRolledBackWhenAnErrorOccurs(): void + { + $flush = $this->em->flush()->willThrow(RuntimeException::class); + + $wrappedIterable = $this->helper->wrapIterable([1, 2, 3], 1); + + self::expectException(RuntimeException::class); + $flush->shouldBeCalledOnce(); + $this->em->beginTransaction()->shouldBeCalledOnce(); + $this->em->commit()->shouldNotBeCalled(); + $this->em->rollback()->shouldBeCalledOnce(); + + foreach ($wrappedIterable as $item) { + // Iterable needs to be iterated for the logic to be invoked + } + } +} diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index a20ed693..fab1db1e 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -11,6 +11,7 @@ use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Response; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -18,6 +19,8 @@ use Shlinkio\Shlink\Core\Util\UrlValidator; class UrlValidatorTest extends TestCase { + use ProphecyTrait; + private UrlValidator $urlValidator; private ObjectProphecy $httpClient; private UrlShortenerOptions $options; @@ -37,7 +40,7 @@ class UrlValidatorTest extends TestCase $request->shouldBeCalledOnce(); $this->expectException(InvalidUrlException::class); - $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar'); + $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar', null); } /** @test */ @@ -54,19 +57,29 @@ class UrlValidatorTest extends TestCase ], )->willReturn(new Response()); - $this->urlValidator->validateUrl($expectedUrl); + $this->urlValidator->validateUrl($expectedUrl, null); $request->shouldHaveBeenCalledOnce(); } - /** @test */ - public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void + /** + * @test + * @dataProvider provideDisabledCombinations + */ + public function noCheckIsPerformedWhenUrlValidationIsDisabled(?bool $doValidate, bool $validateUrl): void { $request = $this->httpClient->request(Argument::cetera())->willReturn(new Response()); - $this->options->validateUrl = false; + $this->options->validateUrl = $validateUrl; - $this->urlValidator->validateUrl(''); + $this->urlValidator->validateUrl('', $doValidate); $request->shouldNotHaveBeenCalled(); } + + public function provideDisabledCombinations(): iterable + { + yield 'config is disabled and no runtime option is provided' => [null, false]; + yield 'config is enabled but runtime option is disabled' => [false, true]; + yield 'both config and runtime option are disabled' => [false, false]; + } } diff --git a/module/Core/test/Visit/VisitLocatorTest.php b/module/Core/test/Visit/VisitLocatorTest.php index d856262c..e9f1a2d5 100644 --- a/module/Core/test/Visit/VisitLocatorTest.php +++ b/module/Core/test/Visit/VisitLocatorTest.php @@ -9,6 +9,7 @@ use Exception; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\MethodProphecy; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -31,6 +32,8 @@ use function sprintf; class VisitLocatorTest extends TestCase { + use ProphecyTrait; + private VisitLocator $visitService; private ObjectProphecy $em; private ObjectProphecy $repo; diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index a4b692d5..2381a73a 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Repository\VisitRepository; @@ -17,6 +18,8 @@ use function range; class VisitsStatsHelperTest extends TestCase { + use ProphecyTrait; + private VisitsStatsHelper $helper; private ObjectProphecy $em; @@ -38,7 +41,7 @@ class VisitsStatsHelperTest extends TestCase $stats = $this->helper->getVisitsStats(); - $this->assertEquals(new VisitsStats($expectedCount), $stats); + self::assertEquals(new VisitsStats($expectedCount), $stats); $count->shouldHaveBeenCalledOnce(); $getRepo->shouldHaveBeenCalledOnce(); } diff --git a/module/Rest/config/auth.config.php b/module/Rest/config/auth.config.php index 99141364..0779502f 100644 --- a/module/Rest/config/auth.config.php +++ b/module/Rest/config/auth.config.php @@ -14,37 +14,16 @@ return [ Action\ShortUrl\SingleStepCreateShortUrlAction::class, ConfigProvider::UNVERSIONED_HEALTH_ENDPOINT_NAME, ], - - 'plugins' => [ - 'factories' => [ - Authentication\Plugin\ApiKeyHeaderPlugin::class => ConfigAbstractFactory::class, - ], - 'aliases' => [ - Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME => - Authentication\Plugin\ApiKeyHeaderPlugin::class, - ], - ], ], 'dependencies' => [ 'factories' => [ - Authentication\AuthenticationPluginManager::class => - Authentication\AuthenticationPluginManagerFactory::class, - Authentication\RequestToHttpAuthPlugin::class => ConfigAbstractFactory::class, - Middleware\AuthenticationMiddleware::class => ConfigAbstractFactory::class, ], ], ConfigAbstractFactory::class => [ - Authentication\Plugin\ApiKeyHeaderPlugin::class => [Service\ApiKeyService::class], - - Authentication\RequestToHttpAuthPlugin::class => [Authentication\AuthenticationPluginManager::class], - - Middleware\AuthenticationMiddleware::class => [ - Authentication\RequestToHttpAuthPlugin::class, - 'config.auth.routes_whitelist', - ], + Middleware\AuthenticationMiddleware::class => [Service\ApiKeyService::class, 'config.auth.routes_whitelist'], ], ]; diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 258404ef..bdd9d3a9 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -8,6 +8,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; +use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Tag\TagService; @@ -36,6 +37,7 @@ return [ Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, + Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -72,6 +74,7 @@ return [ Action\Tag\DeleteTagsAction::class => [TagService::class], Action\Tag\CreateTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], + Action\Domain\ListDomainsAction::class => [DomainService::class, 'config.url_shortener.domain.hostname'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 0bde3da0..64333254 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -12,7 +12,7 @@ return [ 'routes' => [ Action\HealthAction::getRouteDef(), - // Short codes + // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ $contentNegotiationMiddleware, $dropDomainMiddleware, @@ -36,6 +36,9 @@ return [ Action\Tag\CreateTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\MercureInfoAction::getRouteDef(), ], diff --git a/module/Rest/src/Action/Domain/ListDomainsAction.php b/module/Rest/src/Action/Domain/ListDomainsAction.php new file mode 100644 index 00000000..7362123a --- /dev/null +++ b/module/Rest/src/Action/Domain/ListDomainsAction.php @@ -0,0 +1,51 @@ +domainService = $domainService; + $this->defaultDomain = $defaultDomain; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain); + + return new JsonResponse([ + 'domains' => [ + 'data' => [ + $this->mapDomain($this->defaultDomain, true), + ...map($regularDomains, fn (Domain $domain) => $this->mapDomain($domain->getAuthority())), + ], + ], + ]); + } + + private function mapDomain(string $domain, bool $isDefault = false): array + { + return [ + 'domain' => $domain, + 'isDefault' => $isDefault, + ]; + } +} diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index feed626d..8d4ea777 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -31,7 +31,7 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction $tags = $shortUrlData->getTags(); $shortUrlMeta = $shortUrlData->getMeta(); - $shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, $shortUrlMeta); + $shortUrl = $this->urlShortener->shorten($longUrl, $tags, $shortUrlMeta); $transformer = new ShortUrlDataTransformer($this->domainConfig); return new JsonResponse($transformer->transform($shortUrl)); diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index 97097808..28941579 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -8,6 +8,8 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class CreateShortUrlAction extends AbstractCreateShortUrlAction { @@ -19,14 +21,16 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction */ protected function buildShortUrlData(Request $request): CreateShortUrlData { - $postData = (array) $request->getParsedBody(); - if (! isset($postData['longUrl'])) { + $payload = (array) $request->getParsedBody(); + if (! isset($payload['longUrl'])) { throw ValidationException::fromArray([ 'longUrl' => 'A URL was not provided', ]); } - $meta = ShortUrlMeta::fromRawData($postData); - return new CreateShortUrlData($postData['longUrl'], (array) ($postData['tags'] ?? []), $meta); + $payload[ShortUrlMetaInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); + $meta = ShortUrlMeta::fromRawData($payload); + + return new CreateShortUrlData($payload['longUrl'], (array) ($payload['tags'] ?? []), $meta); } } diff --git a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php index 46385556..fe8c44aa 100644 --- a/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/SingleStepCreateShortUrlAction.php @@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\CreateShortUrlData; +use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; +use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction @@ -32,19 +34,23 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction protected function buildShortUrlData(Request $request): CreateShortUrlData { $query = $request->getQueryParams(); + $apiKey = $query['apiKey'] ?? ''; + $longUrl = $query['longUrl'] ?? null; - if (! $this->apiKeyService->check($query['apiKey'] ?? '')) { + if (! $this->apiKeyService->check($apiKey)) { throw ValidationException::fromArray([ 'apiKey' => 'No API key was provided or it is not valid', ]); } - if (! isset($query['longUrl'])) { + if ($longUrl === null) { throw ValidationException::fromArray([ 'longUrl' => 'A URL was not provided', ]); } - return new CreateShortUrlData($query['longUrl']); + return new CreateShortUrlData($longUrl, [], ShortUrlMeta::fromRawData([ + ShortUrlMetaInputFilter::API_KEY => $apiKey, + ])); } } diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php index 08f617c2..8aaf907b 100644 --- a/module/Rest/src/Action/Tag/CreateTagsAction.php +++ b/module/Rest/src/Action/Tag/CreateTagsAction.php @@ -10,6 +10,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +/** @deprecated */ class CreateTagsAction extends AbstractRestAction { protected const ROUTE_PATH = '/tags'; diff --git a/module/Rest/src/Authentication/AuthenticationPluginManager.php b/module/Rest/src/Authentication/AuthenticationPluginManager.php deleted file mode 100644 index 9cd8894e..00000000 --- a/module/Rest/src/Authentication/AuthenticationPluginManager.php +++ /dev/null @@ -1,12 +0,0 @@ -has('config') ? $container->get('config') : []; - return new AuthenticationPluginManager($container, $config['auth']['plugins'] ?? []); - } -} diff --git a/module/Rest/src/Authentication/AuthenticationPluginManagerInterface.php b/module/Rest/src/Authentication/AuthenticationPluginManagerInterface.php deleted file mode 100644 index 838f4ae9..00000000 --- a/module/Rest/src/Authentication/AuthenticationPluginManagerInterface.php +++ /dev/null @@ -1,11 +0,0 @@ -apiKeyService = $apiKeyService; - } - - /** - * @throws VerifyAuthenticationException - */ - public function verify(ServerRequestInterface $request): void - { - $apiKey = $request->getHeaderLine(self::HEADER_NAME); - if (! $this->apiKeyService->check($apiKey)) { - throw VerifyAuthenticationException::forInvalidApiKey(); - } - } - - public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface - { - return $response; - } -} diff --git a/module/Rest/src/Authentication/Plugin/AuthenticationPluginInterface.php b/module/Rest/src/Authentication/Plugin/AuthenticationPluginInterface.php deleted file mode 100644 index 9ae0949f..00000000 --- a/module/Rest/src/Authentication/Plugin/AuthenticationPluginInterface.php +++ /dev/null @@ -1,19 +0,0 @@ -authPluginManager = $authPluginManager; - } - - /** - * @throws MissingAuthenticationException - */ - public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface - { - if (! $this->hasAnySupportedHeader($request)) { - throw MissingAuthenticationException::fromExpectedTypes(self::SUPPORTED_AUTH_HEADERS); - } - - return $this->authPluginManager->get($this->getFirstAvailableHeader($request)); - } - - private function hasAnySupportedHeader(ServerRequestInterface $request): bool - { - return array_reduce( - self::SUPPORTED_AUTH_HEADERS, - fn (bool $carry, string $header) => $carry || $request->hasHeader($header), - false, - ); - } - - private function getFirstAvailableHeader(ServerRequestInterface $request): string - { - $foundHeaders = array_filter(self::SUPPORTED_AUTH_HEADERS, [$request, 'hasHeader']); - return array_shift($foundHeaders) ?? ''; - } -} diff --git a/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php b/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php deleted file mode 100644 index b8002431..00000000 --- a/module/Rest/src/Authentication/RequestToHttpAuthPluginInterface.php +++ /dev/null @@ -1,16 +0,0 @@ -apiKeyService = $apiKeyService; $this->routesWhitelist = $routesWhitelist; - $this->requestToAuthPlugin = $requestToAuthPlugin; } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -39,10 +43,20 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa return $handler->handle($request); } - $plugin = $this->requestToAuthPlugin->fromRequest($request); - $plugin->verify($request); - $response = $handler->handle($request); + $apiKey = self::apiKeyFromRequest($request); + if (empty($apiKey)) { + throw MissingAuthenticationException::fromExpectedTypes([self::API_KEY_HEADER]); + } - return $plugin->update($request, $response); + if (! $this->apiKeyService->check($apiKey)) { + throw VerifyAuthenticationException::forInvalidApiKey(); + } + + return $handler->handle($request); + } + + public static function apiKeyFromRequest(Request $request): string + { + return $request->getHeaderLine(self::API_KEY_HEADER); } } diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index f60c0ad1..171142a1 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -11,7 +11,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Rest\Authentication; use function array_merge; use function implode; @@ -27,9 +26,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa // Add Allow-Origin header $response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin')) - ->withHeader('Access-Control-Expose-Headers', implode(', ', [ - Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME, - ])); + ->withHeader('Access-Control-Expose-Headers', AuthenticationMiddleware::API_KEY_HEADER); if ($request->getMethod() !== self::METHOD_OPTIONS) { return $response; } diff --git a/module/Rest/test-api/Action/CreateShortUrlActionTest.php b/module/Rest/test-api/Action/CreateShortUrlActionTest.php index 79b7ba1e..c9bf6fe5 100644 --- a/module/Rest/test-api/Action/CreateShortUrlActionTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlActionTest.php @@ -20,9 +20,9 @@ class CreateShortUrlActionTest extends ApiTestCase $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags']; [$statusCode, $payload] = $this->createShortUrl(); - $this->assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals(self::STATUS_OK, $statusCode); foreach ($expectedKeys as $key) { - $this->assertArrayHasKey($key, $payload); + self::assertArrayHasKey($key, $payload); } } @@ -31,8 +31,8 @@ class CreateShortUrlActionTest extends ApiTestCase { [$statusCode, $payload] = $this->createShortUrl(['customSlug' => 'my cool slug']); - $this->assertEquals(self::STATUS_OK, $statusCode); - $this->assertEquals('my-cool-slug', $payload['shortCode']); + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('my-cool-slug', $payload['shortCode']); } /** @@ -46,17 +46,17 @@ class CreateShortUrlActionTest extends ApiTestCase [$statusCode, $payload] = $this->createShortUrl(['customSlug' => $slug, 'domain' => $domain]); - $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - $this->assertEquals($detail, $payload['detail']); - $this->assertEquals('INVALID_SLUG', $payload['type']); - $this->assertEquals('Invalid custom slug', $payload['title']); - $this->assertEquals($slug, $payload['customSlug']); + self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals($detail, $payload['detail']); + self::assertEquals('INVALID_SLUG', $payload['type']); + self::assertEquals('Invalid custom slug', $payload['title']); + self::assertEquals($slug, $payload['customSlug']); if ($domain !== null) { - $this->assertEquals($domain, $payload['domain']); + self::assertEquals($domain, $payload['domain']); } else { - $this->assertArrayNotHasKey('domain', $payload); + self::assertArrayNotHasKey('domain', $payload); } } @@ -65,8 +65,8 @@ class CreateShortUrlActionTest extends ApiTestCase { [$statusCode, ['tags' => $tags]] = $this->createShortUrl(['tags' => ['foo', 'bar', 'baz']]); - $this->assertEquals(self::STATUS_OK, $statusCode); - $this->assertEquals(['foo', 'bar', 'baz'], $tags); + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals(['foo', 'bar', 'baz'], $tags); } /** @@ -77,14 +77,14 @@ class CreateShortUrlActionTest extends ApiTestCase { [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl(['maxVisits' => $maxVisits]); - $this->assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals(self::STATUS_OK, $statusCode); // Last request to the short URL will return a 404, and the rest, a 302 for ($i = 0; $i < $maxVisits; $i++) { - $this->assertEquals(self::STATUS_FOUND, $this->callShortUrl($shortCode)->getStatusCode()); + self::assertEquals(self::STATUS_FOUND, $this->callShortUrl($shortCode)->getStatusCode()); } $lastResp = $this->callShortUrl($shortCode); - $this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); } public function provideMaxVisits(): array @@ -99,11 +99,11 @@ class CreateShortUrlActionTest extends ApiTestCase 'validSince' => Chronos::now()->addDay()->toAtomString(), ]); - $this->assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals(self::STATUS_OK, $statusCode); // Request to the short URL will return a 404 since it's not valid yet $lastResp = $this->callShortUrl($shortCode); - $this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); } /** @test */ @@ -113,11 +113,11 @@ class CreateShortUrlActionTest extends ApiTestCase 'validUntil' => Chronos::now()->subDay()->toAtomString(), ]); - $this->assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals(self::STATUS_OK, $statusCode); // Request to the short URL will return a 404 since it's no longer valid $lastResp = $this->callShortUrl($shortCode); - $this->assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $lastResp->getStatusCode()); } /** @@ -131,9 +131,9 @@ class CreateShortUrlActionTest extends ApiTestCase $body['findIfExists'] = true; [$secondStatusCode, ['shortCode' => $secondShortCode]] = $this->createShortUrl($body); - $this->assertEquals(self::STATUS_OK, $firstStatusCode); - $this->assertEquals(self::STATUS_OK, $secondStatusCode); - $this->assertEquals($firstShortCode, $secondShortCode); + self::assertEquals(self::STATUS_OK, $firstStatusCode); + self::assertEquals(self::STATUS_OK, $secondStatusCode); + self::assertEquals($firstShortCode, $secondShortCode); } public function provideMatchingBodies(): iterable @@ -167,8 +167,8 @@ class CreateShortUrlActionTest extends ApiTestCase 'domain' => $domain, ]); - $this->assertEquals(self::STATUS_OK, $firstStatusCode); - $this->assertEquals(self::STATUS_BAD_REQUEST, $secondStatusCode); + self::assertEquals(self::STATUS_OK, $firstStatusCode); + self::assertEquals(self::STATUS_BAD_REQUEST, $secondStatusCode); } public function provideConflictingSlugs(): iterable @@ -188,9 +188,9 @@ class CreateShortUrlActionTest extends ApiTestCase 'findIfExists' => true, ]); - $this->assertEquals(self::STATUS_OK, $firstStatusCode); - $this->assertEquals(self::STATUS_OK, $secondStatusCode); - $this->assertNotEquals($firstShortCode, $secondShortCode); + self::assertEquals(self::STATUS_OK, $firstStatusCode); + self::assertEquals(self::STATUS_OK, $secondStatusCode); + self::assertNotEquals($firstShortCode, $secondShortCode); } /** @@ -201,8 +201,8 @@ class CreateShortUrlActionTest extends ApiTestCase { [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $longUrl]); - $this->assertEquals(self::STATUS_OK, $statusCode); - $this->assertEquals($payload['longUrl'], $longUrl); + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals($payload['longUrl'], $longUrl); } public function provideIdn(): iterable @@ -220,12 +220,12 @@ class CreateShortUrlActionTest extends ApiTestCase [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url]); - $this->assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - $this->assertEquals('INVALID_URL', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid URL', $payload['title']); - $this->assertEquals($url, $payload['url']); + self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_URL', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid URL', $payload['title']); + self::assertEquals($url, $payload['url']); } /** @test */ @@ -238,10 +238,10 @@ class CreateShortUrlActionTest extends ApiTestCase $getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode); $payload = $this->getJsonResponsePayload($getResp); - $this->assertEquals(self::STATUS_OK, $createStatusCode); - $this->assertEquals(self::STATUS_OK, $getResp->getStatusCode()); - $this->assertArrayHasKey('domain', $payload); - $this->assertNull($payload['domain']); + self::assertEquals(self::STATUS_OK, $createStatusCode); + self::assertEquals(self::STATUS_OK, $getResp->getStatusCode()); + self::assertArrayHasKey('domain', $payload); + self::assertNull($payload['domain']); } /** diff --git a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php index ef32190b..7c66ff0b 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlActionTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlActionTest.php @@ -23,13 +23,13 @@ class DeleteShortUrlActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain)); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Short URL not found', $payload['title']); - $this->assertEquals($shortCode, $payload['shortCode']); - $this->assertEquals($domain, $payload['domain'] ?? null); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals($shortCode, $payload['shortCode']); + self::assertEquals($domain, $payload['domain'] ?? null); } /** @test */ @@ -37,18 +37,18 @@ class DeleteShortUrlActionTest extends ApiTestCase { // Generate visits first for ($i = 0; $i < 20; $i++) { - $this->assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode()); + self::assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode()); } $expectedDetail = 'Impossible to delete short URL with short code "abc123" since it has more than "15" visits.'; $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123'); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE_DELETION', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Cannot delete short URL', $payload['title']); + self::assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode()); + self::assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $payload['status']); + self::assertEquals('INVALID_SHORTCODE_DELETION', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Cannot delete short URL', $payload['title']); } /** @test */ @@ -60,10 +60,10 @@ class DeleteShortUrlActionTest extends ApiTestCase $fetchWithDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com'); $fetchWithoutDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789'); - $this->assertEquals(self::STATUS_OK, $fetchWithDomainBefore->getStatusCode()); - $this->assertEquals(self::STATUS_OK, $fetchWithoutDomainBefore->getStatusCode()); - $this->assertEquals(self::STATUS_NO_CONTENT, $deleteResp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $fetchWithDomainAfter->getStatusCode()); - $this->assertEquals(self::STATUS_OK, $fetchWithoutDomainAfter->getStatusCode()); + self::assertEquals(self::STATUS_OK, $fetchWithDomainBefore->getStatusCode()); + self::assertEquals(self::STATUS_OK, $fetchWithoutDomainBefore->getStatusCode()); + self::assertEquals(self::STATUS_NO_CONTENT, $deleteResp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $fetchWithDomainAfter->getStatusCode()); + self::assertEquals(self::STATUS_OK, $fetchWithoutDomainAfter->getStatusCode()); } } diff --git a/module/Rest/test-api/Action/EditShortUrlActionTest.php b/module/Rest/test-api/Action/EditShortUrlActionTest.php index b5cd4fd4..e6b37eba 100644 --- a/module/Rest/test-api/Action/EditShortUrlActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlActionTest.php @@ -41,9 +41,9 @@ class EditShortUrlActionTest extends ApiTestCase ]); $metaAfterResetting = $this->findShortUrlMetaByShortCode($shortCode); - $this->assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode()); - $this->assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode()); - $this->assertEquals($resetMeta, $metaAfterResetting); + self::assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode()); + self::assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode()); + self::assertEquals($resetMeta, $metaAfterResetting); self::assertArraySubset($meta, $metaAfterEditing); } @@ -84,10 +84,10 @@ class EditShortUrlActionTest extends ApiTestCase 'longUrl' => $longUrl, ]]); - $this->assertEquals($expectedStatus, $resp->getStatusCode()); + self::assertEquals($expectedStatus, $resp->getStatusCode()); if ($expectedError !== null) { $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals($expectedError, $payload['type']); + self::assertEquals($expectedError, $payload['type']); } } @@ -110,13 +110,13 @@ class EditShortUrlActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Short URL not found', $payload['title']); - $this->assertEquals($shortCode, $payload['shortCode']); - $this->assertEquals($domain, $payload['domain'] ?? null); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals($shortCode, $payload['shortCode']); + self::assertEquals($domain, $payload['domain'] ?? null); } /** @test */ @@ -129,11 +129,11 @@ class EditShortUrlActionTest extends ApiTestCase ]]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - $this->assertEquals('INVALID_ARGUMENT', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid data', $payload['title']); + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); } /** @@ -154,10 +154,10 @@ class EditShortUrlActionTest extends ApiTestCase ]]); $editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url)); - $this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); - $this->assertEquals($domain, $editedShortUrl['domain']); - $this->assertEquals($expectedUrl, $editedShortUrl['longUrl']); - $this->assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null); + self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); + self::assertEquals($domain, $editedShortUrl['domain']); + self::assertEquals($expectedUrl, $editedShortUrl['longUrl']); + self::assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null); } public function provideDomains(): iterable diff --git a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php index 0433a388..84d2af80 100644 --- a/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTagsActionTest.php @@ -20,11 +20,11 @@ class EditShortUrlTagsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => []]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - $this->assertEquals('INVALID_ARGUMENT', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid data', $payload['title']); + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); } /** @@ -42,13 +42,13 @@ class EditShortUrlTagsActionTest extends ApiTestCase ]]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Short URL not found', $payload['title']); - $this->assertEquals($shortCode, $payload['shortCode']); - $this->assertEquals($domain, $payload['domain'] ?? null); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals($shortCode, $payload['shortCode']); + self::assertEquals($domain, $payload['domain'] ?? null); } /** @test */ @@ -67,8 +67,8 @@ class EditShortUrlTagsActionTest extends ApiTestCase $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com'), ); - $this->assertEquals(self::STATUS_OK, $setTagsWithDomain->getStatusCode()); - $this->assertEquals([], $fetchWithoutDomain['tags']); - $this->assertEquals(['bar', 'foo'], $fetchWithDomain['tags']); + self::assertEquals(self::STATUS_OK, $setTagsWithDomain->getStatusCode()); + self::assertEquals([], $fetchWithoutDomain['tags']); + self::assertEquals(['bar', 'foo'], $fetchWithDomain['tags']); } } diff --git a/module/Rest/test-api/Action/GlobalVisitsActionTest.php b/module/Rest/test-api/Action/GlobalVisitsActionTest.php index 8e4f5e11..b6767c0f 100644 --- a/module/Rest/test-api/Action/GlobalVisitsActionTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsActionTest.php @@ -14,8 +14,8 @@ class GlobalVisitsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, '/visits'); $payload = $this->getJsonResponsePayload($resp); - $this->assertArrayHasKey('visits', $payload); - $this->assertArrayHasKey('visitsCount', $payload['visits']); - $this->assertEquals(7, $payload['visits']['visitsCount']); + self::assertArrayHasKey('visits', $payload); + self::assertArrayHasKey('visitsCount', $payload['visits']); + self::assertEquals(7, $payload['visits']['visitsCount']); } } diff --git a/module/Rest/test-api/Action/ListDomainsTest.php b/module/Rest/test-api/Action/ListDomainsTest.php new file mode 100644 index 00000000..045197e8 --- /dev/null +++ b/module/Rest/test-api/Action/ListDomainsTest.php @@ -0,0 +1,37 @@ +callApiWithKey(self::METHOD_GET, '/domains'); + $respPayload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertEquals([ + 'domains' => [ + 'data' => [ + [ + 'domain' => 'doma.in', + 'isDefault' => true, + ], + [ + 'domain' => 'example.com', + 'isDefault' => false, + ], + [ + 'domain' => 'some-domain.com', + 'isDefault' => false, + ], + ], + ], + ], $respPayload); + } +} diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 7d4e51a7..2f1cf484 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -110,8 +110,8 @@ class ListShortUrlsTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); $respPayload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_OK, $resp->getStatusCode()); - $this->assertEquals([ + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertEquals([ 'shortUrls' => [ 'data' => $expectedShortUrls, 'pagination' => $this->buildPagination(count($expectedShortUrls)), @@ -137,7 +137,15 @@ class ListShortUrlsTest extends ApiTestCase self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, ]]; - yield [['orderBy' => ['shortCode' => 'DESC']], [ + yield [['orderBy' => ['shortCode' => 'DESC']], [ // Deprecated + 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, + ]]; + yield [['orderBy' => 'shortCode-DESC'], [ self::SHORT_URL_DOCS, self::SHORT_URL_CUSTOM_DOMAIN, self::SHORT_URL_META, diff --git a/module/Rest/test-api/Action/ListTagsActionTest.php b/module/Rest/test-api/Action/ListTagsActionTest.php index 0690d4f2..9191b4e0 100644 --- a/module/Rest/test-api/Action/ListTagsActionTest.php +++ b/module/Rest/test-api/Action/ListTagsActionTest.php @@ -18,7 +18,7 @@ class ListTagsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(['tags' => $expectedTags], $payload); + self::assertEquals(['tags' => $expectedTags], $payload); } public function provideQueries(): iterable diff --git a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php index d76d7946..cf1a7212 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlActionTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlActionTest.php @@ -29,9 +29,9 @@ class ResolveShortUrlActionTest extends ApiTestCase $visitResp = $this->callShortUrl($shortCode); $fetchResp = $this->callApiWithKey(self::METHOD_GET, $url); - $this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode()); - $this->assertEquals(self::STATUS_OK, $fetchResp->getStatusCode()); + self::assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode()); + self::assertEquals(self::STATUS_OK, $fetchResp->getStatusCode()); } public function provideDisabledMeta(): iterable @@ -55,12 +55,12 @@ class ResolveShortUrlActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain)); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Short URL not found', $payload['title']); - $this->assertEquals($shortCode, $payload['shortCode']); - $this->assertEquals($domain, $payload['domain'] ?? null); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals($shortCode, $payload['shortCode']); + self::assertEquals($domain, $payload['domain'] ?? null); } } diff --git a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php index ea39a267..6e2463a2 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsActionTest.php @@ -27,13 +27,13 @@ class ShortUrlVisitsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain, '/visits')); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('INVALID_SHORTCODE', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Short URL not found', $payload['title']); - $this->assertEquals($shortCode, $payload['shortCode']); - $this->assertEquals($domain, $payload['domain'] ?? null); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals($shortCode, $payload['shortCode']); + self::assertEquals($domain, $payload['domain'] ?? null); } /** @@ -52,8 +52,8 @@ class ShortUrlVisitsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, (string) $url); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1); - $this->assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []); + self::assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1); + self::assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []); } public function provideDomains(): iterable diff --git a/module/Rest/test-api/Action/TagVisitsActionTest.php b/module/Rest/test-api/Action/TagVisitsActionTest.php index 94e592f6..d0f9838b 100644 --- a/module/Rest/test-api/Action/TagVisitsActionTest.php +++ b/module/Rest/test-api/Action/TagVisitsActionTest.php @@ -19,9 +19,9 @@ class TagVisitsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag)); $payload = $this->getJsonResponsePayload($resp); - $this->assertArrayHasKey('visits', $payload); - $this->assertArrayHasKey('data', $payload['visits']); - $this->assertCount($expectedVisitsAmount, $payload['visits']['data']); + self::assertArrayHasKey('visits', $payload); + self::assertArrayHasKey('data', $payload['visits']); + self::assertCount($expectedVisitsAmount, $payload['visits']['data']); } public function provideTags(): iterable @@ -37,10 +37,10 @@ class TagVisitsActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits'); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('TAG_NOT_FOUND', $payload['type']); - $this->assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']); - $this->assertEquals('Tag not found', $payload['title']); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('TAG_NOT_FOUND', $payload['type']); + self::assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']); + self::assertEquals('Tag not found', $payload['title']); } } diff --git a/module/Rest/test-api/Action/UpdateTagActionTest.php b/module/Rest/test-api/Action/UpdateTagActionTest.php index eb70685a..de0b1594 100644 --- a/module/Rest/test-api/Action/UpdateTagActionTest.php +++ b/module/Rest/test-api/Action/UpdateTagActionTest.php @@ -20,11 +20,11 @@ class UpdateTagActionTest extends ApiTestCase $resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => $body]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - $this->assertEquals('INVALID_ARGUMENT', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid data', $payload['title']); + self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); + self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); + self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); } public function provideInvalidBody(): iterable @@ -45,11 +45,11 @@ class UpdateTagActionTest extends ApiTestCase ]]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - $this->assertEquals('TAG_NOT_FOUND', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Tag not found', $payload['title']); + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('TAG_NOT_FOUND', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Tag not found', $payload['title']); } /** @test */ @@ -63,11 +63,11 @@ class UpdateTagActionTest extends ApiTestCase ]]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_CONFLICT, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_CONFLICT, $payload['status']); - $this->assertEquals('TAG_CONFLICT', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Tag conflict', $payload['title']); + self::assertEquals(self::STATUS_CONFLICT, $resp->getStatusCode()); + self::assertEquals(self::STATUS_CONFLICT, $payload['status']); + self::assertEquals('TAG_CONFLICT', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Tag conflict', $payload['title']); } /** @test */ @@ -78,6 +78,6 @@ class UpdateTagActionTest extends ApiTestCase 'newName' => 'foo', ]]); - $this->assertEquals(self::STATUS_NO_CONTENT, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NO_CONTENT, $resp->getStatusCode()); } } diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index aa90451f..61dbd2c5 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -4,31 +4,23 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Middleware; -use Shlinkio\Shlink\Rest\Authentication\Plugin; -use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use function implode; -use function sprintf; - class AuthenticationTest extends ApiTestCase { /** @test */ public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void { - $expectedDetail = sprintf( - 'Expected one of the following authentication headers, ["%s"], but none were provided', - implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS), - ); + $expectedDetail = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided'; $resp = $this->callApi(self::METHOD_GET, '/short-urls'); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - $this->assertEquals('INVALID_AUTHORIZATION', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid authorization', $payload['title']); + self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); + self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); + self::assertEquals('INVALID_AUTHORIZATION', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid authorization', $payload['title']); } /** @@ -41,16 +33,16 @@ class AuthenticationTest extends ApiTestCase $resp = $this->callApi(self::METHOD_GET, '/short-urls', [ 'headers' => [ - Plugin\ApiKeyHeaderPlugin::HEADER_NAME => $apiKey, + 'X-Api-Key' => $apiKey, ], ]); $payload = $this->getJsonResponsePayload($resp); - $this->assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); - $this->assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - $this->assertEquals('INVALID_API_KEY', $payload['type']); - $this->assertEquals($expectedDetail, $payload['detail']); - $this->assertEquals('Invalid API key', $payload['title']); + self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); + self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); + self::assertEquals('INVALID_API_KEY', $payload['type']); + self::assertEquals($expectedDetail, $payload['detail']); + self::assertEquals('Invalid API key', $payload['title']); } public function provideInvalidApiKeys(): iterable diff --git a/module/Rest/test-api/Middleware/ImplicitOptionsTest.php b/module/Rest/test-api/Middleware/ImplicitOptionsTest.php index ddfaa62d..f9f140c2 100644 --- a/module/Rest/test-api/Middleware/ImplicitOptionsTest.php +++ b/module/Rest/test-api/Middleware/ImplicitOptionsTest.php @@ -15,8 +15,8 @@ class ImplicitOptionsTest extends ApiTestCase { $resp = $this->callApi(self::METHOD_OPTIONS, '/short-urls'); - $this->assertEquals(self::STATUS_NO_CONTENT, $resp->getStatusCode()); - $this->assertEmpty((string) $resp->getBody()); + self::assertEquals(self::STATUS_NO_CONTENT, $resp->getStatusCode()); + self::assertEmpty((string) $resp->getBody()); } /** @test */ @@ -25,7 +25,7 @@ class ImplicitOptionsTest extends ApiTestCase $resp = $this->callApi(self::METHOD_OPTIONS, '/short-urls'); $allowedMethods = $resp->getHeaderLine('Allow'); - $this->assertEquals([ + self::assertEquals([ self::METHOD_GET, self::METHOD_POST, ], explode(',', $allowedMethods)); diff --git a/module/Rest/test/Action/Domain/ListDomainsActionTest.php b/module/Rest/test/Action/Domain/ListDomainsActionTest.php new file mode 100644 index 00000000..6750d105 --- /dev/null +++ b/module/Rest/test/Action/Domain/ListDomainsActionTest.php @@ -0,0 +1,61 @@ +domainService = $this->prophesize(DomainServiceInterface::class); + $this->action = new ListDomainsAction($this->domainService->reveal(), 'foo.com'); + } + + /** @test */ + public function domainsAreProperlyListed(): void + { + $listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([ + new Domain('bar.com'), + new Domain('baz.com'), + ]); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); + $payload = $resp->getPayload(); + + self::assertEquals([ + 'domains' => [ + 'data' => [ + [ + 'domain' => 'foo.com', + 'isDefault' => true, + ], + [ + 'domain' => 'bar.com', + 'isDefault' => false, + ], + [ + 'domain' => 'baz.com', + 'isDefault' => false, + ], + ], + ], + ], $payload); + $listDomains->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index 2ec68d25..bdfc9ccd 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -10,12 +10,15 @@ use Exception; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Rest\Action\HealthAction; class HealthActionTest extends TestCase { + use ProphecyTrait; + private HealthAction $action; private ObjectProphecy $conn; @@ -37,14 +40,14 @@ class HealthActionTest extends TestCase $resp = $this->action->handle(new ServerRequest()); $payload = $resp->getPayload(); - $this->assertEquals(200, $resp->getStatusCode()); - $this->assertEquals('pass', $payload['status']); - $this->assertEquals('1.2.3', $payload['version']); - $this->assertEquals([ + self::assertEquals(200, $resp->getStatusCode()); + self::assertEquals('pass', $payload['status']); + self::assertEquals('1.2.3', $payload['version']); + self::assertEquals([ 'about' => 'https://shlink.io', 'project' => 'https://github.com/shlinkio/shlink', ], $payload['links']); - $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + self::assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); $ping->shouldHaveBeenCalledOnce(); } @@ -57,14 +60,14 @@ class HealthActionTest extends TestCase $resp = $this->action->handle(new ServerRequest()); $payload = $resp->getPayload(); - $this->assertEquals(503, $resp->getStatusCode()); - $this->assertEquals('fail', $payload['status']); - $this->assertEquals('1.2.3', $payload['version']); - $this->assertEquals([ + self::assertEquals(503, $resp->getStatusCode()); + self::assertEquals('fail', $payload['status']); + self::assertEquals('1.2.3', $payload['version']); + self::assertEquals([ 'about' => 'https://shlink.io', 'project' => 'https://github.com/shlinkio/shlink', ], $payload['links']); - $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + self::assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); $ping->shouldHaveBeenCalledOnce(); } @@ -77,14 +80,14 @@ class HealthActionTest extends TestCase $resp = $this->action->handle(new ServerRequest()); $payload = $resp->getPayload(); - $this->assertEquals(503, $resp->getStatusCode()); - $this->assertEquals('fail', $payload['status']); - $this->assertEquals('1.2.3', $payload['version']); - $this->assertEquals([ + self::assertEquals(503, $resp->getStatusCode()); + self::assertEquals('fail', $payload['status']); + self::assertEquals('1.2.3', $payload['version']); + self::assertEquals([ 'about' => 'https://shlink.io', 'project' => 'https://github.com/shlinkio/shlink', ], $payload['links']); - $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + self::assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); $ping->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index d40b3f70..eca4177d 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; @@ -17,6 +18,8 @@ use Shlinkio\Shlink\Rest\Exception\MercureException; class MercureInfoActionTest extends TestCase { + use ProphecyTrait; + private ObjectProphecy $provider; public function setUp(): void @@ -87,11 +90,11 @@ class MercureInfoActionTest extends TestCase $resp = $action->handle(ServerRequestFactory::fromGlobals()); $payload = $resp->getPayload(); - $this->assertArrayHasKey('mercureHubUrl', $payload); - $this->assertEquals('http://foobar.com/.well-known/mercure', $payload['mercureHubUrl']); - $this->assertArrayHasKey('token', $payload); - $this->assertArrayHasKey('jwtExpiration', $payload); - $this->assertEquals( + self::assertArrayHasKey('mercureHubUrl', $payload); + self::assertEquals('http://foobar.com/.well-known/mercure', $payload['mercureHubUrl']); + self::assertArrayHasKey('token', $payload); + self::assertArrayHasKey('jwtExpiration', $payload); + self::assertEquals( Chronos::now()->addDays($days ?? 1)->startOfDay(), Chronos::parse($payload['jwtExpiration'])->startOfDay(), ); diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index 66f1eaaa..91e6014c 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -20,6 +21,8 @@ use function strpos; class CreateShortUrlActionTest extends TestCase { + use ProphecyTrait; + private const DOMAIN_CONFIG = [ 'schema' => 'http', 'hostname' => 'foo.com', @@ -45,20 +48,24 @@ class CreateShortUrlActionTest extends TestCase * @test * @dataProvider provideRequestBodies */ - public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta): void + public function properShortcodeConversionReturnsData(array $body, ShortUrlMeta $expectedMeta, ?string $apiKey): void { $shortUrl = new ShortUrl(''); - $shorten = $this->urlShortener->urlToShortCode( + $shorten = $this->urlShortener->shorten( Argument::type('string'), Argument::type('array'), $expectedMeta, )->willReturn($shortUrl); $request = ServerRequestFactory::fromGlobals()->withParsedBody($body); + if ($apiKey !== null) { + $request = $request->withHeader('X-Api-Key', $apiKey); + } + $response = $this->action->handle($request); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0); + self::assertEquals(200, $response->getStatusCode()); + self::assertTrue(strpos($response->getBody()->getContents(), $shortUrl->toString(self::DOMAIN_CONFIG)) > 0); $shorten->shouldHaveBeenCalledOnce(); } @@ -74,8 +81,14 @@ class CreateShortUrlActionTest extends TestCase 'domain' => 'my-domain.com', ]; - yield [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()]; - yield [$fullMeta, ShortUrlMeta::fromRawData($fullMeta)]; + yield 'no data' => [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty(), null]; + yield 'all data' => [$fullMeta, ShortUrlMeta::fromRawData($fullMeta), null]; + yield 'all data and API key' => (static function (array $meta): array { + $apiKey = 'abc123'; + $meta['apiKey'] = $apiKey; + + return [$meta, ShortUrlMeta::fromRawData($meta), $apiKey]; + })($fullMeta); } /** @@ -85,7 +98,7 @@ class CreateShortUrlActionTest extends TestCase public function anInvalidDomainReturnsError(string $domain): void { $shortUrl = new ShortUrl(''); - $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn($shortUrl); + $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl); $request = (new ServerRequest())->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', diff --git a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php index cbd8e2bc..6f724c4e 100644 --- a/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/DeleteShortUrlActionTest.php @@ -7,12 +7,15 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\DeleteShortUrlAction; class DeleteShortUrlActionTest extends TestCase { + use ProphecyTrait; + private DeleteShortUrlAction $action; private ObjectProphecy $service; @@ -30,7 +33,7 @@ class DeleteShortUrlActionTest extends TestCase $resp = $this->action->handle(new ServerRequest()); - $this->assertEquals(204, $resp->getStatusCode()); + self::assertEquals(204, $resp->getStatusCode()); $deleteByShortCode->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php index d6247d9f..087b4298 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -15,6 +16,8 @@ use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlAction; class EditShortUrlActionTest extends TestCase { + use ProphecyTrait; + private EditShortUrlAction $action; private ObjectProphecy $shortUrlService; @@ -49,7 +52,7 @@ class EditShortUrlActionTest extends TestCase $resp = $this->action->handle($request); - $this->assertEquals(204, $resp->getStatusCode()); + self::assertEquals(204, $resp->getStatusCode()); $updateMeta->shouldHaveBeenCalled(); } } diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php index d7a86844..2fa6f456 100644 --- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -15,6 +16,8 @@ use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction; class EditShortUrlTagsActionTest extends TestCase { + use ProphecyTrait; + private EditShortUrlTagsAction $action; private ObjectProphecy $shortUrlService; @@ -42,6 +45,6 @@ class EditShortUrlTagsActionTest extends TestCase (new ServerRequest())->withAttribute('shortCode', 'abc123') ->withParsedBody(['tags' => []]), ); - $this->assertEquals(200, $response->getStatusCode()); + self::assertEquals(200, $response->getStatusCode()); } } diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php index 3d98c2fe..741eceb5 100644 --- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php @@ -10,27 +10,27 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Rest\Action\ShortUrl\ListShortUrlsAction; class ListShortUrlsActionTest extends TestCase { + use ProphecyTrait; + private ListShortUrlsAction $action; private ObjectProphecy $service; - private ObjectProphecy $logger; public function setUp(): void { $this->service = $this->prophesize(ShortUrlService::class); - $this->logger = $this->prophesize(LoggerInterface::class); $this->action = new ListShortUrlsAction($this->service->reveal(), [ 'hostname' => 'doma.in', 'schema' => 'https', - ], $this->logger->reveal()); + ]); } /** @@ -59,10 +59,10 @@ class ListShortUrlsActionTest extends TestCase $response = $this->action->handle((new ServerRequest())->withQueryParams($query)); $payload = $response->getPayload(); - $this->assertArrayHasKey('shortUrls', $payload); - $this->assertArrayHasKey('data', $payload['shortUrls']); - $this->assertEquals([], $payload['shortUrls']['data']); - $this->assertEquals(200, $response->getStatusCode()); + self::assertArrayHasKey('shortUrls', $payload); + self::assertArrayHasKey('data', $payload['shortUrls']); + self::assertEquals([], $payload['shortUrls']['data']); + self::assertEquals(200, $response->getStatusCode()); $listShortUrls->shouldHaveBeenCalledOnce(); } diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index a62b1f95..d61f0f64 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -16,6 +17,8 @@ use function strpos; class ResolveShortUrlActionTest extends TestCase { + use ProphecyTrait; + private ResolveShortUrlAction $action; private ObjectProphecy $urlResolver; @@ -35,7 +38,7 @@ class ResolveShortUrlActionTest extends TestCase $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); $response = $this->action->handle($request); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); + self::assertEquals(200, $response->getStatusCode()); + self::assertTrue(strpos($response->getBody()->getContents(), 'http://domain.com/foo/bar') > 0); } } diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index d63a83b9..62005c8d 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -8,6 +8,7 @@ use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -18,6 +19,8 @@ use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; class SingleStepCreateShortUrlActionTest extends TestCase { + use ProphecyTrait; + private SingleStepCreateShortUrlAction $action; private ObjectProphecy $urlShortener; private ObjectProphecy $apiKeyService; @@ -69,18 +72,18 @@ class SingleStepCreateShortUrlActionTest extends TestCase 'longUrl' => 'http://foobar.com', ]); $findApiKey = $this->apiKeyService->check('abc123')->willReturn(true); - $generateShortCode = $this->urlShortener->urlToShortCode( + $generateShortCode = $this->urlShortener->shorten( Argument::that(function (string $argument): string { Assert::assertEquals('http://foobar.com', $argument); return $argument; }), [], - ShortUrlMeta::createEmpty(), + ShortUrlMeta::fromRawData(['apiKey' => 'abc123']), )->willReturn(new ShortUrl('')); $resp = $this->action->handle($request); - $this->assertEquals(200, $resp->getStatusCode()); + self::assertEquals(200, $resp->getStatusCode()); $findApiKey->shouldHaveBeenCalled(); $generateShortCode->shouldHaveBeenCalled(); } diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php index 33aa0ba7..f63c0afc 100644 --- a/module/Rest/test/Action/Tag/CreateTagsActionTest.php +++ b/module/Rest/test/Action/Tag/CreateTagsActionTest.php @@ -7,12 +7,15 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Doctrine\Common\Collections\ArrayCollection; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction; class CreateTagsActionTest extends TestCase { + use ProphecyTrait; + private CreateTagsAction $action; private ObjectProphecy $tagService; @@ -33,7 +36,7 @@ class CreateTagsActionTest extends TestCase $response = $this->action->handle($request); - $this->assertEquals(200, $response->getStatusCode()); + self::assertEquals(200, $response->getStatusCode()); $deleteTags->shouldHaveBeenCalled(); } diff --git a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php index 819a608a..b167ee2c 100644 --- a/module/Rest/test/Action/Tag/DeleteTagsActionTest.php +++ b/module/Rest/test/Action/Tag/DeleteTagsActionTest.php @@ -6,12 +6,15 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction; class DeleteTagsActionTest extends TestCase { + use ProphecyTrait; + private DeleteTagsAction $action; private ObjectProphecy $tagService; @@ -32,7 +35,7 @@ class DeleteTagsActionTest extends TestCase $response = $this->action->handle($request); - $this->assertEquals(204, $response->getStatusCode()); + self::assertEquals(204, $response->getStatusCode()); $deleteTags->shouldHaveBeenCalled(); } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 461ddd3f..2f675536 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; @@ -15,6 +16,8 @@ use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction; class ListTagsActionTest extends TestCase { + use ProphecyTrait; + private ListTagsAction $action; private ObjectProphecy $tagService; @@ -37,7 +40,7 @@ class ListTagsActionTest extends TestCase $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query)); $payload = $resp->getPayload(); - $this->assertEquals([ + self::assertEquals([ 'tags' => [ 'data' => $tags, ], @@ -65,7 +68,7 @@ class ListTagsActionTest extends TestCase $resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true'])); $payload = $resp->getPayload(); - $this->assertEquals([ + self::assertEquals([ 'tags' => [ 'data' => ['foo', 'bar'], 'stats' => $stats, diff --git a/module/Rest/test/Action/Tag/UpdateTagActionTest.php b/module/Rest/test/Action/Tag/UpdateTagActionTest.php index 11b2c1c4..b82c8c2e 100644 --- a/module/Rest/test/Action/Tag/UpdateTagActionTest.php +++ b/module/Rest/test/Action/Tag/UpdateTagActionTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ValidationException; @@ -14,6 +15,8 @@ use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction; class UpdateTagActionTest extends TestCase { + use ProphecyTrait; + private UpdateTagAction $action; private ObjectProphecy $tagService; @@ -54,7 +57,7 @@ class UpdateTagActionTest extends TestCase $resp = $this->action->handle($request); - $this->assertEquals(204, $resp->getStatusCode()); + self::assertEquals(204, $resp->getStatusCode()); $rename->shouldHaveBeenCalled(); } } diff --git a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php index 7e1dec06..6b91ba56 100644 --- a/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/GlobalVisitsActionTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Visit; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; @@ -14,6 +15,8 @@ use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction; class GlobalVisitsActionTest extends TestCase { + use ProphecyTrait; + private GlobalVisitsAction $action; private ObjectProphecy $helper; @@ -33,7 +36,7 @@ class GlobalVisitsActionTest extends TestCase $resp = $this->action->handle(ServerRequestFactory::fromGlobals()); $payload = $resp->getPayload(); - $this->assertEquals($payload, ['visits' => $stats]); + self::assertEquals($payload, ['visits' => $stats]); $getStats->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 07508acf..25e71006 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -10,6 +10,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -19,6 +20,8 @@ use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction; class ShortUrlVisitsActionTest extends TestCase { + use ProphecyTrait; + private ShortUrlVisitsAction $action; private ObjectProphecy $visitsTracker; @@ -37,7 +40,7 @@ class ShortUrlVisitsActionTest extends TestCase )->shouldBeCalledOnce(); $response = $this->action->handle((new ServerRequest())->withAttribute('shortCode', $shortCode)); - $this->assertEquals(200, $response->getStatusCode()); + self::assertEquals(200, $response->getStatusCode()); } /** @test */ @@ -60,6 +63,6 @@ class ShortUrlVisitsActionTest extends TestCase 'itemsPerPage' => '10', ]), ); - $this->assertEquals(200, $response->getStatusCode()); + self::assertEquals(200, $response->getStatusCode()); } } diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php index 863bc725..53dbf8f2 100644 --- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php @@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter; use Laminas\Paginator\Paginator; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Service\VisitsTracker; @@ -16,6 +17,8 @@ use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction; class TagVisitsActionTest extends TestCase { + use ProphecyTrait; + private TagVisitsAction $action; private ObjectProphecy $visitsTracker; @@ -35,7 +38,7 @@ class TagVisitsActionTest extends TestCase $response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag)); - $this->assertEquals(200, $response->getStatusCode()); + self::assertEquals(200, $response->getStatusCode()); $getVisits->shouldHaveBeenCalledOnce(); } } diff --git a/module/Rest/test/Authentication/AuthenticationPluginManagerFactoryTest.php b/module/Rest/test/Authentication/AuthenticationPluginManagerFactoryTest.php deleted file mode 100644 index 326054d7..00000000 --- a/module/Rest/test/Authentication/AuthenticationPluginManagerFactoryTest.php +++ /dev/null @@ -1,57 +0,0 @@ -factory = new AuthenticationPluginManagerFactory(); - } - - /** - * @test - * @dataProvider provideConfigs - */ - public function serviceIsProperlyCreatedWithExpectedPlugins(?array $config, array $expectedPlugins): void - { - $instance = ($this->factory)(new ServiceManager(['services' => [ - 'config' => $config, - ]])); - - $this->assertEquals($expectedPlugins, $this->getPlugins($instance)); - } - - private function getPlugins(AuthenticationPluginManager $pluginManager): array - { - return (fn () => $this->services)->call($pluginManager); - } - - public function provideConfigs(): iterable - { - yield [null, []]; - yield [[], []]; - yield [['auth' => []], []]; - yield [['auth' => [ - 'plugins' => [], - ]], []]; - yield [['auth' => [ - 'plugins' => [ - 'services' => $plugins = [ - 'foo' => $this->prophesize(AuthenticationPluginInterface::class)->reveal(), - 'bar' => $this->prophesize(AuthenticationPluginInterface::class)->reveal(), - ], - ], - ]], $plugins]; - } -} diff --git a/module/Rest/test/Authentication/Plugin/ApiKeyHeaderPluginTest.php b/module/Rest/test/Authentication/Plugin/ApiKeyHeaderPluginTest.php deleted file mode 100644 index e6a9c23d..00000000 --- a/module/Rest/test/Authentication/Plugin/ApiKeyHeaderPluginTest.php +++ /dev/null @@ -1,66 +0,0 @@ -apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); - $this->plugin = new ApiKeyHeaderPlugin($this->apiKeyService->reveal()); - } - - /** @test */ - public function verifyThrowsExceptionWhenApiKeyIsNotValid(): void - { - $apiKey = 'abc-ABC'; - $check = $this->apiKeyService->check($apiKey)->willReturn(false); - $check->shouldBeCalledOnce(); - - $this->expectException(VerifyAuthenticationException::class); - $this->expectExceptionMessage('Provided API key does not exist or is invalid'); - - $this->plugin->verify($this->createRequest($apiKey)); - } - - /** @test */ - public function verifyDoesNotThrowExceptionWhenApiKeyIsValid(): void - { - $apiKey = 'abc-ABC'; - $check = $this->apiKeyService->check($apiKey)->willReturn(true); - - $this->plugin->verify($this->createRequest($apiKey)); - - $check->shouldHaveBeenCalledOnce(); - } - - /** @test */ - public function updateReturnsResponseAsIs(): void - { - $apiKey = 'abc-ABC'; - $response = new Response(); - - $returnedResponse = $this->plugin->update($this->createRequest($apiKey), $response); - - $this->assertSame($response, $returnedResponse); - } - - private function createRequest(string $apiKey): ServerRequestInterface - { - return (new ServerRequest())->withHeader(ApiKeyHeaderPlugin::HEADER_NAME, $apiKey); - } -} diff --git a/module/Rest/test/Authentication/RequestToAuthPluginTest.php b/module/Rest/test/Authentication/RequestToAuthPluginTest.php deleted file mode 100644 index 5fac50dc..00000000 --- a/module/Rest/test/Authentication/RequestToAuthPluginTest.php +++ /dev/null @@ -1,69 +0,0 @@ -pluginManager = $this->prophesize(AuthenticationPluginManagerInterface::class); - $this->requestToPlugin = new RequestToHttpAuthPlugin($this->pluginManager->reveal()); - } - - /** @test */ - public function exceptionIsFoundWhenNoneOfTheSupportedMethodsIsFound(): void - { - $request = new ServerRequest(); - - $this->expectException(MissingAuthenticationException::class); - $this->expectExceptionMessage(sprintf( - 'Expected one of the following authentication headers, ["%s"], but none were provided', - implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS), - )); - - $this->requestToPlugin->fromRequest($request); - } - - /** - * @test - * @dataProvider provideHeaders - */ - public function properPluginIsFetchedWhenAnyAuthTypeIsFound(array $headers, string $expectedHeader): void - { - $request = new ServerRequest(); - foreach ($headers as $header => $value) { - $request = $request->withHeader($header, $value); - } - - $plugin = $this->prophesize(AuthenticationPluginInterface::class); - $getPlugin = $this->pluginManager->get($expectedHeader)->willReturn($plugin->reveal()); - - $this->requestToPlugin->fromRequest($request); - - $getPlugin->shouldHaveBeenCalledOnce(); - } - - public function provideHeaders(): iterable - { - yield 'API key header' => [[ - ApiKeyHeaderPlugin::HEADER_NAME => 'foobar', - ], ApiKeyHeaderPlugin::HEADER_NAME]; - } -} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 8032a854..69f745ff 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -21,8 +21,8 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - $this->assertArrayHasKey('routes', $config); - $this->assertArrayHasKey('dependencies', $config); + self::assertArrayHasKey('routes', $config); + self::assertArrayHasKey('dependencies', $config); } /** @@ -35,7 +35,7 @@ class ConfigProviderTest extends TestCase $config = $configProvider(); - $this->assertEquals($expected, $config['routes']); + self::assertEquals($expected, $config['routes']); } public function provideRoutesConfig(): iterable diff --git a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php index eee6058e..afe2a54e 100644 --- a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php +++ b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php @@ -25,12 +25,12 @@ class MissingAuthenticationExceptionTest extends TestCase $e = MissingAuthenticationException::fromExpectedTypes($expectedTypes); - $this->assertEquals($expectedMessage, $e->getMessage()); - $this->assertEquals($expectedMessage, $e->getDetail()); - $this->assertEquals('Invalid authorization', $e->getTitle()); - $this->assertEquals('INVALID_AUTHORIZATION', $e->getType()); - $this->assertEquals(401, $e->getStatus()); - $this->assertEquals(['expectedTypes' => $expectedTypes], $e->getAdditionalData()); + self::assertEquals($expectedMessage, $e->getMessage()); + self::assertEquals($expectedMessage, $e->getDetail()); + self::assertEquals('Invalid authorization', $e->getTitle()); + self::assertEquals('INVALID_AUTHORIZATION', $e->getType()); + self::assertEquals(401, $e->getStatus()); + self::assertEquals(['expectedTypes' => $expectedTypes], $e->getAdditionalData()); } public function provideExpectedTypes(): iterable diff --git a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php index 28563c5f..3221041d 100644 --- a/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php +++ b/module/Rest/test/Exception/VerifyAuthenticationExceptionTest.php @@ -14,6 +14,6 @@ class VerifyAuthenticationExceptionTest extends TestCase { $e = VerifyAuthenticationException::forInvalidApiKey(); - $this->assertEquals('Provided API key does not exist or is invalid.', $e->getMessage()); + self::assertEquals('Provided API key does not exist or is invalid.', $e->getMessage()); } } diff --git a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php index cd002f60..db721780 100644 --- a/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php +++ b/module/Rest/test/Middleware/AuthenticationMiddlewareTest.php @@ -7,39 +7,37 @@ namespace ShlinkioTest\Shlink\Rest\Middleware; use Fig\Http\Message\RequestMethodInterface; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; +use Laminas\Diactoros\ServerRequestFactory; use Mezzio\Router\Route; use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Rest\Action\HealthAction; -use Shlinkio\Shlink\Rest\Authentication\Plugin\AuthenticationPluginInterface; -use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPluginInterface; +use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException; +use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; +use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use function Laminas\Stratigility\middleware; class AuthenticationMiddlewareTest extends TestCase { + use ProphecyTrait; + private AuthenticationMiddleware $middleware; - private ObjectProphecy $requestToPlugin; - private ObjectProphecy $logger; + private ObjectProphecy $apiKeyService; + private ObjectProphecy $handler; public function setUp(): void { - $this->requestToPlugin = $this->prophesize(RequestToHttpAuthPluginInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); - - $this->middleware = new AuthenticationMiddleware( - $this->requestToPlugin->reveal(), - [HealthAction::class], - $this->logger->reveal(), - ); + $this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class); + $this->middleware = new AuthenticationMiddleware($this->apiKeyService->reveal(), [HealthAction::class]); + $this->handler = $this->prophesize(RequestHandlerInterface::class); } /** @@ -48,16 +46,13 @@ class AuthenticationMiddlewareTest extends TestCase */ public function someWhiteListedSituationsFallbackToNextMiddleware(ServerRequestInterface $request): void { - $handler = $this->prophesize(RequestHandlerInterface::class); - $handle = $handler->handle($request)->willReturn(new Response()); - $fromRequest = $this->requestToPlugin->fromRequest(Argument::any())->willReturn( - $this->prophesize(AuthenticationPluginInterface::class)->reveal(), - ); + $handle = $this->handler->handle($request)->willReturn(new Response()); + $checkApiKey = $this->apiKeyService->check(Argument::any()); - $this->middleware->process($request, $handler->reveal()); + $this->middleware->process($request, $this->handler->reveal()); $handle->shouldHaveBeenCalledOnce(); - $fromRequest->shouldNotHaveBeenCalled(); + $checkApiKey->shouldNotHaveBeenCalled(); } public function provideWhitelistedRequests(): iterable @@ -81,30 +76,70 @@ class AuthenticationMiddlewareTest extends TestCase )->withMethod(RequestMethodInterface::METHOD_OPTIONS)]; } - /** @test */ - public function updatedResponseIsReturnedWhenVerificationPasses(): void + /** + * @test + * @dataProvider provideRequestsWithoutApiKey + */ + public function throwsExceptionWhenNoApiKeyIsProvided(ServerRequestInterface $request): void { - $newResponse = new Response(); - $request = (new ServerRequest())->withAttribute( + $this->apiKeyService->check(Argument::any())->shouldNotBeCalled(); + $this->handler->handle($request)->shouldNotBeCalled(); + $this->expectException(MissingAuthenticationException::class); + $this->expectExceptionMessage( + 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided', + ); + + $this->middleware->process($request, $this->handler->reveal()); + } + + public function provideRequestsWithoutApiKey(): iterable + { + $baseRequest = ServerRequestFactory::fromGlobals()->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []), ); - $plugin = $this->prophesize(AuthenticationPluginInterface::class); - $verify = $plugin->verify($request)->will(function (): void { - }); - $update = $plugin->update($request, Argument::type(ResponseInterface::class))->willReturn($newResponse); - $fromRequest = $this->requestToPlugin->fromRequest(Argument::any())->willReturn($plugin->reveal()); + yield 'no api key' => [$baseRequest]; + yield 'empty api key' => [$baseRequest->withHeader('X-Api-Key', '')]; + } - $handler = $this->prophesize(RequestHandlerInterface::class); - $handle = $handler->handle($request)->willReturn(new Response()); - $response = $this->middleware->process($request, $handler->reveal()); + /** @test */ + public function throwsExceptionWhenProvidedApiKeyIsInvalid(): void + { + $apiKey = 'abc123'; + $request = ServerRequestFactory::fromGlobals() + ->withAttribute( + RouteResult::class, + RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []), + ) + ->withHeader('X-Api-Key', $apiKey); + + $this->apiKeyService->check($apiKey)->willReturn(false)->shouldBeCalledOnce(); + $this->handler->handle($request)->shouldNotBeCalled(); + $this->expectException(VerifyAuthenticationException::class); + $this->expectExceptionMessage('Provided API key does not exist or is invalid'); + + $this->middleware->process($request, $this->handler->reveal()); + } + + /** @test */ + public function validApiKeyFallsBackToNextMiddleware(): void + { + $apiKey = 'abc123'; + $request = ServerRequestFactory::fromGlobals() + ->withAttribute( + RouteResult::class, + RouteResult::fromRoute(new Route('bar', $this->getDummyMiddleware()), []), + ) + ->withHeader('X-Api-Key', $apiKey); + + $handle = $this->handler->handle($request)->willReturn(new Response()); + $checkApiKey = $this->apiKeyService->check($apiKey)->willReturn(true); + + $this->middleware->process($request, $this->handler->reveal()); - $this->assertSame($response, $newResponse); - $verify->shouldHaveBeenCalledOnce(); - $update->shouldHaveBeenCalledOnce(); $handle->shouldHaveBeenCalledOnce(); - $fromRequest->shouldHaveBeenCalledOnce(); + $checkApiKey->shouldHaveBeenCalledOnce(); } private function getDummyMiddleware(): MiddlewareInterface diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php index 5adae27d..98549e70 100644 --- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Stream; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ProphecyInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -18,6 +19,8 @@ use function array_shift; class BodyParserMiddlewareTest extends TestCase { + use ProphecyTrait; + private BodyParserMiddleware $middleware; public function setUp(): void @@ -35,7 +38,7 @@ class BodyParserMiddlewareTest extends TestCase $request->getMethod()->willReturn($method); $request->getParsedBody()->willReturn([]); - $this->assertHandlingRequestJustFallsBackToNext($request); + self::assertHandlingRequestJustFallsBackToNext($request); } public function provideIgnoredRequestMethods(): iterable @@ -52,7 +55,7 @@ class BodyParserMiddlewareTest extends TestCase $request->getMethod()->willReturn('POST'); $request->getParsedBody()->willReturn(['foo' => 'bar']); - $this->assertHandlingRequestJustFallsBackToNext($request); + self::assertHandlingRequestJustFallsBackToNext($request); } private function assertHandlingRequestJustFallsBackToNext(ProphecyInterface $requestMock): void diff --git a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php index 5cc99fb3..03675fce 100644 --- a/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php +++ b/module/Rest/test/Middleware/CrossDomainMiddlewareTest.php @@ -10,15 +10,17 @@ use Mezzio\Router\Route; use Mezzio\Router\RouteResult; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Server\RequestHandlerInterface; -use Shlinkio\Shlink\Rest\Authentication; use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware; use function Laminas\Stratigility\middleware; class CrossDomainMiddlewareTest extends TestCase { + use ProphecyTrait; + private CrossDomainMiddleware $middleware; private ObjectProphecy $handler; @@ -37,13 +39,13 @@ class CrossDomainMiddlewareTest extends TestCase $response = $this->middleware->process(new ServerRequest(), $this->handler->reveal()); $headers = $response->getHeaders(); - $this->assertSame($originalResponse, $response); - $this->assertEquals(404, $response->getStatusCode()); - $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); - $this->assertArrayNotHasKey('Access-Control-Expose-Headers', $headers); - $this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); - $this->assertArrayNotHasKey('Access-Control-Max-Age', $headers); - $this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); + self::assertSame($originalResponse, $response); + self::assertEquals(404, $response->getStatusCode()); + self::assertArrayNotHasKey('Access-Control-Allow-Origin', $headers); + self::assertArrayNotHasKey('Access-Control-Expose-Headers', $headers); + self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); + self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); + self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); } /** @test */ @@ -56,18 +58,15 @@ class CrossDomainMiddlewareTest extends TestCase (new ServerRequest())->withHeader('Origin', 'local'), $this->handler->reveal(), ); - $this->assertNotSame($originalResponse, $response); + self::assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); - $this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertEquals( - Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME, - $response->getHeaderLine('Access-Control-Expose-Headers'), - ); - $this->assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); - $this->assertArrayNotHasKey('Access-Control-Max-Age', $headers); - $this->assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); + self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers')); + self::assertArrayNotHasKey('Access-Control-Allow-Methods', $headers); + self::assertArrayNotHasKey('Access-Control-Max-Age', $headers); + self::assertArrayNotHasKey('Access-Control-Allow-Headers', $headers); } /** @test */ @@ -81,19 +80,16 @@ class CrossDomainMiddlewareTest extends TestCase $this->handler->handle(Argument::any())->willReturn($originalResponse)->shouldBeCalledOnce(); $response = $this->middleware->process($request, $this->handler->reveal()); - $this->assertNotSame($originalResponse, $response); + self::assertNotSame($originalResponse, $response); $headers = $response->getHeaders(); - $this->assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); - $this->assertEquals( - Authentication\Plugin\ApiKeyHeaderPlugin::HEADER_NAME, - $response->getHeaderLine('Access-Control-Expose-Headers'), - ); - $this->assertArrayHasKey('Access-Control-Allow-Methods', $headers); - $this->assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age')); - $this->assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers')); - $this->assertEquals(204, $response->getStatusCode()); + self::assertEquals('local', $response->getHeaderLine('Access-Control-Allow-Origin')); + self::assertEquals('X-Api-Key', $response->getHeaderLine('Access-Control-Expose-Headers')); + self::assertArrayHasKey('Access-Control-Allow-Methods', $headers); + self::assertEquals('1000', $response->getHeaderLine('Access-Control-Max-Age')); + self::assertEquals('foo, bar, baz', $response->getHeaderLine('Access-Control-Allow-Headers')); + self::assertEquals(204, $response->getStatusCode()); } /** @@ -112,8 +108,8 @@ class CrossDomainMiddlewareTest extends TestCase $response = $this->middleware->process($request, $this->handler->reveal()); - $this->assertEquals($response->getHeaderLine('Access-Control-Allow-Methods'), $expectedAllowedMethods); - $this->assertEquals(204, $response->getStatusCode()); + self::assertEquals($response->getHeaderLine('Access-Control-Allow-Methods'), $expectedAllowedMethods); + self::assertEquals(204, $response->getStatusCode()); } public function provideRouteResults(): iterable @@ -145,7 +141,7 @@ class CrossDomainMiddlewareTest extends TestCase $response = $this->middleware->process($request, $this->handler->reveal()); - $this->assertEquals($expectedStatus, $response->getStatusCode()); + self::assertEquals($expectedStatus, $response->getStatusCode()); } public function provideMethods(): iterable diff --git a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php index 73fdd07e..9d216913 100644 --- a/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php +++ b/module/Rest/test/Middleware/EmptyResponseImplicitOptionsMiddlewareFactoryTest.php @@ -23,7 +23,7 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase public function serviceIsCreated(): void { $instance = ($this->factory)(); - $this->assertInstanceOf(ImplicitOptionsMiddleware::class, $instance); + self::assertInstanceOf(ImplicitOptionsMiddleware::class, $instance); } /** @test */ @@ -34,6 +34,6 @@ class EmptyResponseImplicitOptionsMiddlewareFactoryTest extends TestCase $ref = new ReflectionObject($instance); $prop = $ref->getProperty('responseFactory'); $prop->setAccessible(true); - $this->assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)()); + self::assertInstanceOf(EmptyResponse::class, $prop->getValue($instance)()); } } diff --git a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php index f70a4d4a..dc4733ff 100644 --- a/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/CreateShortUrlContentNegotiationMiddlewareTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequest; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -16,6 +17,8 @@ use Shlinkio\Shlink\Rest\Middleware\ShortUrl\CreateShortUrlContentNegotiationMid class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase { + use ProphecyTrait; + private CreateShortUrlContentNegotiationMiddleware $middleware; private ObjectProphecy $requestHandler; @@ -33,7 +36,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $resp = $this->middleware->process(new ServerRequest(), $this->requestHandler->reveal()); - $this->assertSame($expectedResp, $resp); + self::assertSame($expectedResp, $resp); } /** @@ -54,7 +57,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $response = $this->middleware->process($request, $this->requestHandler->reveal()); - $this->assertEquals($expectedContentType, $response->getHeaderLine('Content-type')); + self::assertEquals($expectedContentType, $response->getHeaderLine('Content-type')); $handle->shouldHaveBeenCalled(); } @@ -85,7 +88,7 @@ class CreateShortUrlContentNegotiationMiddlewareTest extends TestCase $response = $this->middleware->process($request, $this->requestHandler->reveal()); - $this->assertEquals($expectedBody, (string) $response->getBody()); + self::assertEquals($expectedBody, (string) $response->getBody()); $handle->shouldHaveBeenCalled(); } diff --git a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php index c14a2f5c..918b0a5d 100644 --- a/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DefaultShortCodesLengthMiddlewareTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -17,6 +18,8 @@ use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DefaultShortCodesLengthMiddleware; class DefaultShortCodesLengthMiddlewareTest extends TestCase { + use ProphecyTrait; + private DefaultShortCodesLengthMiddleware $middleware; private ObjectProphecy $handler; diff --git a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php index 9b443602..24f3aecd 100644 --- a/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php +++ b/module/Rest/test/Middleware/ShortUrl/DropDefaultDomainFromRequestMiddlewareTest.php @@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -16,6 +17,8 @@ use Shlinkio\Shlink\Rest\Middleware\ShortUrl\DropDefaultDomainFromRequestMiddlew class DropDefaultDomainFromRequestMiddlewareTest extends TestCase { + use ProphecyTrait; + private DropDefaultDomainFromRequestMiddleware $middleware; private ObjectProphecy $next; diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index c371beab..656541f0 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -16,6 +17,8 @@ use Shlinkio\Shlink\Rest\Service\ApiKeyService; class ApiKeyServiceTest extends TestCase { + use ProphecyTrait; + private ApiKeyService $service; private ObjectProphecy $em; @@ -36,7 +39,7 @@ class ApiKeyServiceTest extends TestCase $key = $this->service->create($date); - $this->assertEquals($date, $key->getExpirationDate()); + self::assertEquals($date, $key->getExpirationDate()); } public function provideCreationDate(): iterable @@ -56,7 +59,7 @@ class ApiKeyServiceTest extends TestCase ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - $this->assertFalse($this->service->check('12345')); + self::assertFalse($this->service->check('12345')); } public function provideInvalidApiKeys(): iterable @@ -74,7 +77,7 @@ class ApiKeyServiceTest extends TestCase ->shouldBeCalledOnce(); $this->em->getRepository(ApiKey::class)->willReturn($repo->reveal()); - $this->assertTrue($this->service->check('12345')); + self::assertTrue($this->service->check('12345')); } /** @test */ @@ -101,10 +104,10 @@ class ApiKeyServiceTest extends TestCase $this->em->flush()->shouldBeCalledOnce(); - $this->assertTrue($key->isEnabled()); + self::assertTrue($key->isEnabled()); $returnedKey = $this->service->disable('12345'); - $this->assertFalse($key->isEnabled()); - $this->assertSame($key, $returnedKey); + self::assertFalse($key->isEnabled()); + self::assertSame($key, $returnedKey); } /** @test */ @@ -119,7 +122,7 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->listKeys(); - $this->assertEquals($expectedApiKeys, $result); + self::assertEquals($expectedApiKeys, $result); } /** @test */ @@ -134,6 +137,6 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->listKeys(true); - $this->assertEquals($expectedApiKeys, $result); + self::assertEquals($expectedApiKeys, $result); } } diff --git a/phpstan.neon b/phpstan.neon index 35b1beda..969b00b4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,6 +2,5 @@ parameters: checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false ignoreErrors: - - '#AbstractQuery::setParameters\(\)#' - '#mustRun\(\)#' - - '#AssociationBuilder::setOrderBy#' + - '#If condition is always false#' diff --git a/phpunit-api.xml b/phpunit-api.xml index 6e481fe5..b38a3c0f 100644 --- a/phpunit-api.xml +++ b/phpunit-api.xml @@ -1,7 +1,7 @@ @@ -11,9 +11,9 @@ - - + + ./module/*/src - - + + diff --git a/phpunit-db.xml b/phpunit-db.xml index 86cdbbc6..a995448f 100644 --- a/phpunit-db.xml +++ b/phpunit-db.xml @@ -1,7 +1,7 @@ @@ -11,11 +11,11 @@ - - + + ./module/*/src/Repository ./module/*/src/**/Repository ./module/*/src/**/**/Repository - - + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 03f73521..68f5263a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ @@ -17,13 +17,14 @@ - - + + ./module/*/src - - - ./module/Core/src/Repository - - - + + + ./module/Core/src/Repository + ./module/Core/src/**/Repository + ./module/Core/src/**/**/Repository + +