Compare commits

...

66 Commits

Author SHA1 Message Date
Alejandro Celaya
b6ed39b18b Merge pull request #1749 from shlinkio/develop
Release 3.5.4
2023-04-12 19:03:25 +02:00
Alejandro Celaya
958c4704f8 Merge pull request #1748 from acelaya-forks/feature/create-error
Feature/create error
2023-04-12 18:52:18 +02:00
Alejandro Celaya
ef075fb0ce Fix test when CLI output viewport is too narrow 2023-04-12 18:36:28 +02:00
Alejandro Celaya
556520583a Update changelog 2023-04-12 18:31:57 +02:00
Alejandro Celaya
399c56a097 Print warning when trying to create short URL from CLI on openswoole in verbose mode 2023-04-12 18:30:02 +02:00
Alejandro Celaya
f078d95588 Capture error on real-time update when creating short URL 2023-04-12 09:25:01 +02:00
Alejandro Celaya
33911afcd6 Merge pull request #1744 from acelaya-forks/feature/regression-fix
Feature/regression fix
2023-04-11 19:13:08 +02:00
Alejandro Celaya
ae8d31e83f Add test case for deeplink long URLs 2023-04-11 17:24:38 +02:00
Alejandro Celaya
72c4052012 Be less restrictive when validating long URLs 2023-04-10 18:05:57 +02:00
Alejandro Celaya
f713a1fa7e Merge pull request #1737 from shlinkio/develop
Release 3.5.3
2023-03-31 22:07:51 +02:00
Alejandro Celaya
62488ac4e5 Merge pull request #1739 from acelaya-forks/feature/import-memory-leak
Feature/import memory leak
2023-03-31 10:00:36 +02:00
Alejandro Celaya
ab4c6e5fca Update changelog 2023-03-31 09:48:08 +02:00
Alejandro Celaya
26f4a969c9 Fix memory leak when importing big amounts of visits 2023-03-31 09:46:05 +02:00
Alejandro Celaya
703965915d Merge pull request #1736 from acelaya-forks/feature/lcobucci-jwt-5
Update to latest shlink-common
2023-03-30 18:45:39 +02:00
Alejandro Celaya
24e38a3cf9 Update to latest shlink-common 2023-03-30 18:33:53 +02:00
Alejandro Celaya
b12cfaedf3 Merge pull request #1730 from acelaya-forks/feature/validate-uris
Feature/validate uris
2023-03-25 13:29:36 +01:00
Alejandro Celaya
71807e698c Update changelog 2023-03-25 11:23:01 +01:00
Alejandro Celaya
1d155298c1 Fix API tests 2023-03-25 11:23:01 +01:00
Alejandro Celaya
4dfc5ae681 Fix DB tests 2023-03-25 11:23:01 +01:00
Alejandro Celaya
26f237069c Fixed unit tests 2023-03-25 11:23:01 +01:00
Alejandro Celaya
b6e1c65c4c Enforce a schema to be provided when short URLs are created 2023-03-25 11:23:00 +01:00
Alejandro Celaya
11f94b8306 Merge pull request #1723 from acelaya-forks/feature/tags-list-performance-join-tags
Feature/tags list performance join tags
2023-03-16 21:57:43 +01:00
Alejandro Celaya
01bcedef7a Simplify how ordering field is resolved in tags list 2023-03-04 11:54:30 +01:00
Alejandro Celaya
e51384fcc0 Reduce duplicated logic when checking if an API key is admin 2023-03-04 10:22:46 +01:00
Alejandro Celaya
83c53c8b2e Add correct index on visits potential_bot column 2023-03-04 09:51:14 +01:00
Alejandro Celaya
1afe08caed Simplify how limits are applied to tags query 2023-03-04 09:50:38 +01:00
Alejandro Celaya
7289833928 Move join on short URLs to tags sub-query 2023-03-03 12:10:41 +01:00
Alejandro Celaya
f4d10df0f3 Delete no longer used spec file 2023-02-27 09:28:27 +01:00
Alejandro Celaya
652b0df054 Use native query builders for all queries/sub-queries in tags list 2023-02-27 09:21:11 +01:00
Alejandro Celaya
0e9ea5027c Merge pull request #1705 from shlinkio/develop
Release 3.5.2
2023-02-16 19:36:59 +01:00
Alejandro Celaya
658303d375 Merge pull request #1706 from acelaya-forks/feature/fix-ms-ci
Comment-out unixodbc-dev installation in CI, as it's already present …
2023-02-16 19:33:08 +01:00
Alejandro Celaya
ccc3a4b584 Comment-out unixodbc-dev installation in CI, as it's already present in Ubuntu 22.04 2023-02-16 19:24:09 +01:00
Alejandro Celaya
ef5ac86e0a Add v3.5.2 to changelog 2023-02-15 20:25:55 +01:00
Alejandro Celaya
91b90b276a Merge pull request #1704 from acelaya-forks/feature/stronger-db-detection
Feature/stronger db detection
2023-02-15 19:19:11 +01:00
Alejandro Celaya
85c32c3c9a Fix CreateDatabaseCommandTest 2023-02-15 18:55:25 +01:00
Alejandro Celaya
40838255a7 Make sure database detection is not affected by the existence of foreign tables 2023-02-15 08:52:17 +01:00
Alejandro Celaya
a67ccb384f Merge pull request #1697 from acelaya-forks/feature/phpunit-10
Feature/phpunit 10
2023-02-13 19:15:33 +01:00
Alejandro Celaya
cb31e5a581 Update to phpcov 9 2023-02-13 19:05:27 +01:00
Alejandro Celaya
3c12a55872 Merge branch 'develop' into feature/phpunit-10 2023-02-13 11:54:49 +01:00
Alejandro Celaya
6da8b11674 Update changelog 2023-02-12 19:52:22 +01:00
Alejandro Celaya
552489611f Merge pull request #1700 from acelaya-forks/feature/optimize-tags-query
Feature/optimize tags query
2023-02-12 19:50:23 +01:00
Alejandro Celaya
e48d0f4f0c Upgrade deps for MSSQL tests 2023-02-12 19:08:20 +01:00
Alejandro Celaya
49b6063501 Fix ordering on Postgres 2023-02-12 13:35:05 +01:00
Alejandro Celaya
dd049feb40 Add migration with new index for short_url_id+potential_bot on visits table 2023-02-12 13:12:09 +01:00
Alejandro Celaya
76a86c452e Optimize tags list query performance by using more subqueries 2023-02-12 13:09:24 +01:00
Alejandro Celaya
41aec15fab Migrate new test to PHPUnit 10 2023-02-10 20:45:09 +01:00
Alejandro Celaya
245cb0e35d Fixed merge conflicts 2023-02-10 20:44:05 +01:00
Alejandro Celaya
7a0b1e8494 Merge pull request #1699 from acelaya-forks/feature/fix-robots-txt
Fix dependency injected in CrawlingHelper
2023-02-10 20:41:10 +01:00
Alejandro Celaya
70c1c9f018 Fix dependency injected in CrawlingHelper 2023-02-10 20:26:18 +01:00
Alejandro Celaya
97e965157b Update changelog 2023-02-09 20:43:07 +01:00
Alejandro Celaya
04bbd471ff Migrate from PHPUnit annotations to native attributes 2023-02-09 20:42:18 +01:00
Alejandro Celaya
650a286982 Update to PHPUnit 10 2023-02-09 09:32:38 +01:00
Alejandro Celaya
ad44a8441a Merge pull request #1694 from acelaya-forks/feature/fix-gha-deprecations
Fix usage of deprecated GitHub actions practices
2023-02-06 21:56:35 +01:00
Alejandro Celaya
b339cf2429 Fix usage of deprecated GitHub actions practices 2023-02-06 21:47:04 +01:00
Alejandro Celaya
9cd97c2f1e Merge pull request #1691 from shlinkio/develop
Release 3.5.1
2023-02-04 17:58:27 +01:00
Alejandro Celaya
a7f6b60cba Merge pull request #1690 from acelaya-forks/feature/uninitialized-prop
Update to latest shlink-common including the cache clear fix for redis replication
2023-02-04 17:51:04 +01:00
Alejandro Celaya
0d7dc50670 Update to latest shlink-common including the cache clear fix for redis replication 2023-02-04 17:40:44 +01:00
Alejandro Celaya
4bc5b9261f Merge pull request #1687 from acelaya-forks/feature/ms-case-sensitive
Feature/ms case sensitive
2023-01-30 11:16:13 +01:00
Alejandro Celaya
fb572d5abb Fix accidentally removed statement in new migration 2023-01-30 10:52:07 +01:00
Alejandro Celaya
8fa4219b30 Update changelog 2023-01-30 10:50:47 +01:00
Alejandro Celaya
a52d0cd419 Ensure short_code column is case sensitive in Microsoft SQL server 2023-01-30 10:49:47 +01:00
Alejandro Celaya
0080ab5132 Merge pull request #1686 from acelaya-forks/feature/loose-mode
Rename loosely mode to loose mode
2023-01-29 11:42:52 +01:00
Alejandro Celaya
8afa582aa5 Create ShortUrlModeTest 2023-01-29 11:32:13 +01:00
Alejandro Celaya
d847c7648e Rename loosely mode to loose mode 2023-01-29 10:30:34 +01:00
Alejandro Celaya
c140db16d1 Improve issue templates requesting roadrunner when appropriate 2023-01-29 09:53:47 +01:00
Alejandro Celaya
adbf7c6f5e Fix twitter badge 2023-01-28 11:15:46 +01:00
243 changed files with 2008 additions and 1810 deletions

View File

@@ -1,7 +1,7 @@
<!-- <!--
Before opening an issue, just take into account that this is a completely free of charge and open source project. Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected. I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed. I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs. You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases. Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
--> -->

View File

@@ -7,18 +7,18 @@ labels: bug
<!-- <!--
Before opening an issue, just take into account that this is a completely free of charge and open source project. Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected. I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed. I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs. You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases. Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs). With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
--> -->
#### How Shlink is set-up #### How Shlink is set up
* Shlink Version: x.y.z * Shlink Version: x.y.z
* PHP Version: x.y.z * PHP Version: x.y.z
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image * How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) * Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary #### Summary
@@ -31,7 +31,7 @@ With that said, please fill in the information requested next. More information
#### Expected behavior #### Expected behavior
<!-- How did you expected to behave? --> <!-- How did you expect it to behave? -->
#### How to reproduce #### How to reproduce

View File

@@ -7,7 +7,7 @@ labels: feature
<!-- <!--
Before opening an issue, just take into account that this is a completely free of charge and open source project. Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected. I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed. I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs. You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases. Try to be polite, and understand it is impossible for an OSS project to cover all use cases.

View File

@@ -7,18 +7,18 @@ labels: question
<!-- <!--
Before opening an issue, just take into account that this is a completely free of charge and open source project. Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected. I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed. I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs. You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases. Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs). With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
--> -->
#### How Shlink is set-up #### How Shlink is set up
* Shlink Version: x.y.z * Shlink Version: x.y.z
* PHP Version: x.y.z * PHP Version: x.y.z
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image * How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z) * Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary #### Summary

View File

@@ -28,7 +28,7 @@ runs:
extensions: ${{ inputs.php-extensions }} extensions: ${{ inputs.php-extensions }}
key: ${{ inputs.extensions-cache-key }} key: ${{ inputs.extensions-cache-key }}
- name: Cache extensions - name: Cache extensions
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: ${{ steps.extcache.outputs.dir }} path: ${{ steps.extcache.outputs.dir }}
key: ${{ steps.extcache.outputs.key }} key: ${{ steps.extcache.outputs.key }}

View File

@@ -27,14 +27,14 @@ jobs:
path: build path: build
- name: Resolve infection args - name: Resolve infection args
id: infection_args id: infection_args
run: echo "::set-output name=args::--logger-github=false" run: echo "args=--logger-github=false" >> $GITHUB_OUTPUT
# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work # TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work
# run: | # run: |
# BRANCH="${GITHUB_REF#refs/heads/}" | # BRANCH="${GITHUB_REF#refs/heads/}" |
# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then # if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then
# echo "::set-output name=args::--logger-github=false" # echo "args=--logger-github=false" >> $GITHUB_OUTPUT
# else # else
# echo "::set-output name=args::--logger-github=false --git-diff-lines --git-diff-base=develop" # echo "args=--logger-github=false --git-diff-lines --git-diff-base=develop" >> $GITHUB_OUTPUT
# fi; # fi;
shell: bash shell: bash
- if: ${{ inputs.test-group == 'unit' }} - if: ${{ inputs.test-group == 'unit' }}

View File

@@ -152,8 +152,8 @@ jobs:
- run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov
- run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov
- run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov
- run: wget https://phar.phpunit.de/phpcov-8.2.1.phar - run: wget https://phar.phpunit.de/phpcov-9.0.0.phar
- run: php phpcov-8.2.1.phar merge build --clover build/clover.xml - run: php phpcov-9.0.0.phar merge build --clover build/clover.xml
- name: Publish coverage - name: Publish coverage
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1
with: with:

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Determine version - name: Determine version
id: determine_version id: determine_version
run: echo "::set-output name=version::${GITHUB_REF#refs/tags/}" run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
shell: bash shell: bash
- uses: './.github/actions/ci-setup' - uses: './.github/actions/ci-setup'
with: with:

View File

@@ -4,6 +4,82 @@ 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). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.5.4] - 2023-04-12
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1742](https://github.com/shlinkio/shlink/issues/1742) Fix URLs using schemas which do not contain `//`, like `mailto:`, to no longer be considered valid.
* [#1743](https://github.com/shlinkio/shlink/issues/1743) Fix Error when trying to create short URLs from CLI on an openswoole context.
Unfortunately the reason are real-time updates do not work with openswoole when outside an openswoole request, so the feature has been disabled for that context.
## [3.5.3] - 2023-03-31
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1715](https://github.com/shlinkio/shlink/issues/1715) Fix short URL creation/edition allowing long URLs without schema. Now a validation error is thrown.
* [#1537](https://github.com/shlinkio/shlink/issues/1537) Fix incorrect list of tags being returned for some author-only API keys.
* [#1738](https://github.com/shlinkio/shlink/issues/1738) Fix memory leak when importing short URLs with many visits.
## [3.5.2] - 2023-02-16
### Added
* *Nothing*
### Changed
* [#1696](https://github.com/shlinkio/shlink/issues/1696) Migrated to PHPUnit 10.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1698](https://github.com/shlinkio/shlink/issues/1698) Fixed error 500 in `robots.txt`.
* [#1688](https://github.com/shlinkio/shlink/issues/1688) Fixed huge performance degradation on `/tags/stats` endpoint.
* [#1693](https://github.com/shlinkio/shlink/issues/1693) Fixed Shlink thinking database already exists if it finds foreign tables.
## [3.5.1] - 2023-02-04
### Added
* *Nothing*
### Changed
* [#1685](https://github.com/shlinkio/shlink/issues/1685) Changed `loosely` mode to `loose`, as it was a typo. The old one keeps working and maps to the new one, but it's considered deprecated.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1682](https://github.com/shlinkio/shlink/issues/1682) Fixed incorrect case-insensitive checks in short URLs when using Microsoft SQL server.
* [#1684](https://github.com/shlinkio/shlink/issues/1684) Fixed entities metadata cache not being cleared at docker container start-up when using redis with replication.
## [3.5.0] - 2023-01-28 ## [3.5.0] - 2023-01-28
### Added ### Added
* [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type. * [#1557](https://github.com/shlinkio/shlink/issues/1557) Added support to dynamically redirect to different long URLs based on the visitor's device type.
@@ -25,9 +101,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`. * [#1662](https://github.com/shlinkio/shlink/issues/1662) Added support to provide openswoole-specific config options via env vars prefixed with `OPENSWOOLE_`.
* [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs. * [#1389](https://github.com/shlinkio/shlink/issues/1389) and [#706](https://github.com/shlinkio/shlink/issues/706) Added support for case-insensitive short URLs.
In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or `loosely`. In order to achieve this, a new env var/config option has been implemented (`SHORT_URL_MODE`), which allows either `strict` or ~~`loosely`~~ `loose`.
Default value is `strict`, but if `loosely` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only. Default value is `strict`, but if `loose` is provided, then short URLs will be matched in a case-insensitive way, and new short URLs will be generated with short-codes in lowercase only.
### Changed ### Changed
* *Nothing* * *Nothing*

View File

@@ -6,7 +6,7 @@
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE)
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio) [![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=twitter&color=blue)](https://twitter.com/shlinkio)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate)

View File

@@ -20,63 +20,61 @@
"akrabat/ip-address-middleware": "^2.1", "akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^2.3", "cakephp/chronos": "^2.3",
"doctrine/migrations": "^3.5", "doctrine/migrations": "^3.5",
"doctrine/orm": "^2.13.3", "doctrine/orm": "^2.14",
"endroid/qr-code": "^4.6", "endroid/qr-code": "^4.7",
"geoip2/geoip2": "^2.13", "geoip2/geoip2": "^2.13",
"guzzlehttp/guzzle": "^7.5", "guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0", "happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.112", "jaybizzle/crawler-detect": "^1.2.112",
"laminas/laminas-config": "^3.7", "laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.11", "laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-diactoros": "^2.19", "laminas/laminas-diactoros": "^2.24",
"laminas/laminas-inputfilter": "^2.22", "laminas/laminas-inputfilter": "^2.24",
"laminas/laminas-servicemanager": "^3.19", "laminas/laminas-servicemanager": "^3.20",
"laminas/laminas-stdlib": "^3.15", "laminas/laminas-stdlib": "^3.16",
"lcobucci/jwt": "^4.2",
"league/uri": "^6.8", "league/uri": "^6.8",
"lstrojny/functional-php": "^1.17", "lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.13", "mezzio/mezzio": "^3.15",
"mezzio/mezzio-fastroute": "^3.7", "mezzio/mezzio-fastroute": "^3.8",
"mezzio/mezzio-problem-details": "^1.7", "mezzio/mezzio-problem-details": "^1.11",
"mezzio/mezzio-swoole": "^4.5", "mezzio/mezzio-swoole": "^4.6",
"mlocati/ip-lib": "^1.18", "mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^3.74", "mobiledetect/mobiledetectlib": "^3.74",
"ocramius/proxy-manager": "^2.14", "ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6", "pagerfanta/core": "^3.7",
"php-middleware/request-id": "^4.1", "php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1", "pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.5", "ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "^5.3", "shlinkio/shlink-common": "^5.4",
"shlinkio/shlink-config": "^2.4", "shlinkio/shlink-config": "^2.4",
"shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^5.0", "shlinkio/shlink-importer": "^5.0",
"shlinkio/shlink-installer": "^8.3", "shlinkio/shlink-installer": "^8.3",
"shlinkio/shlink-ip-geolocation": "^3.2", "shlinkio/shlink-ip-geolocation": "^3.2",
"spiral/roadrunner": "^2.11", "spiral/roadrunner": "^2.12",
"spiral/roadrunner-jobs": "^2.5", "spiral/roadrunner-jobs": "^2.7",
"symfony/console": "^6.1", "symfony/console": "^6.2",
"symfony/filesystem": "^6.1", "symfony/filesystem": "^6.2",
"symfony/lock": "^6.1", "symfony/lock": "^6.2",
"symfony/process": "^6.1", "symfony/process": "^6.2",
"symfony/string": "^6.1" "symfony/string": "^6.2"
}, },
"require-dev": { "require-dev": {
"cebe/php-openapi": "^1.7", "cebe/php-openapi": "^1.7",
"devster/ubench": "^2.1", "devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.4.0", "infection/infection": "^0.26.19",
"infection/infection": "^0.26.15",
"openswoole/ide-helper": "~4.11.5", "openswoole/ide-helper": "~4.11.5",
"phpstan/phpstan": "^1.8", "phpstan/phpstan": "^1.9",
"phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.1", "phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.2", "phpstan/phpstan-symfony": "^1.2",
"phpunit/php-code-coverage": "^9.2", "phpunit/php-code-coverage": "^10.0",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^10.0",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0", "shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.4", "shlinkio/shlink-test-utils": "^3.5",
"symfony/var-dumper": "^6.1", "symfony/var-dumper": "^6.2",
"veewee/composer-run-parallel": "^1.1" "veewee/composer-run-parallel": "^1.2"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -133,7 +131,7 @@
"test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml",
"test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli",
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli", "test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
"infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests", "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5", "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5",

View File

@@ -6,12 +6,40 @@ return [
'entity_manager' => [ 'entity_manager' => [
'connection' => [ 'connection' => [
// MySQL
'user' => 'root', 'user' => 'root',
'password' => 'root', 'password' => 'root',
'driver' => 'pdo_mysql', 'driver' => 'pdo_mysql',
'host' => 'shlink_db_mysql', 'host' => 'shlink_db_mysql',
'dbname' => 'shlink', 'dbname' => 'shlink',
// 'dbname' => 'shlink_foo',
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
// MariaDB
// 'user' => 'root',
// 'password' => 'root',
// 'driver' => 'pdo_mysql',
// 'host' => 'shlink_db_maria',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8mb4',
// Postgres
// 'user' => 'postgres',
// 'password' => 'root',
// 'driver' => 'pdo_pgsql',
// 'host' => 'shlink_db_postgres',
// 'dbname' => 'shlink_foo',
// 'charset' => 'utf8',
// MSSQL
// 'user' => 'sa',
// 'password' => 'Passw0rd!',
// 'driver' => 'pdo_sqlsrv',
// 'host' => 'shlink_db_ms',
// 'dbname' => 'shlink_foo',
// 'driverOptions' => [
// 'TrustServerCertificate' => 'true',
// ],
], ],
], ],

View File

@@ -5,14 +5,16 @@ declare(strict_types=1);
use Monolog\Level; use Monolog\Level;
use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\Common\Logger\LoggerType;
$isSwoole = extension_loaded('openswoole'); use function Shlinkio\Shlink\Config\runningInOpenswoole;
$logToStream = runningInOpenswoole();
return [ return [
'logger' => [ 'logger' => [
'Shlink' => [ 'Shlink' => [
// For swoole, send logs as stream // For openswoole, send logs as stream
'type' => $isSwoole ? LoggerType::STREAM->value : LoggerType::FILE->value, 'type' => $logToStream ? LoggerType::STREAM->value : LoggerType::FILE->value,
'level' => Level::Debug->value, 'level' => Level::Debug->value,
], ],
], ],

View File

@@ -14,7 +14,7 @@ return (static function (): array {
MIN_SHORT_CODES_LENGTH, MIN_SHORT_CODES_LENGTH,
); );
$modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value);
$mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; $mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT;
return [ return [

View File

@@ -13,10 +13,10 @@ const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated.
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag
const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a schema.
const DEFAULT_QR_CODE_SIZE = 300; const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0; const DEFAULT_QR_CODE_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const MIN_TASK_WORKERS = 4; const MIN_TASK_WORKERS = 4;
const MIGRATIONS_TABLE = 'migrations';

View File

@@ -23,10 +23,10 @@ if (file_exists($covFile)) {
} }
$testHelper->createTestDb( $testHelper->createTestDb(
['bin/cli', 'db:create'], createDbCommand: ['bin/cli', 'db:create'],
['bin/cli', 'db:migrate'], migrateDbCommand: ['bin/cli', 'db:migrate'],
['bin/doctrine', 'orm:schema-tool:drop'], dropSchemaCommand: ['bin/doctrine', 'orm:schema-tool:drop'],
['bin/doctrine', 'dbal:run-sql'], runSqlCommand: ['bin/doctrine', 'dbal:run-sql'],
); );
CliTest\CliTestCase::setSeedFixturesCallback( CliTest\CliTestCase::setSeedFixturesCallback(
static fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []), static fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []),

View File

@@ -3,7 +3,7 @@
set -ex set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
curl https://packages.microsoft.com/config/ubuntu/20.04/prod.list > /etc/apt/sources.list.d/mssql-release.list curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql17 ACCEPT_EULA=Y apt-get install msodbcsql18
apt-get install unixodbc-dev # apt-get install unixodbc-dev

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230130090946 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->skipIf(! $this->isMsSql(), 'This only sets MsSQL-specific database options');
$shortUrls = $schema->getTable('short_urls');
$shortCode = $shortUrls->getColumn('short_code');
// Drop the unique index before changing the collation, as the field is part of this index
$shortUrls->dropIndex('unique_short_code_plus_domain');
$shortCode->setPlatformOption('collation', 'Latin1_General_CS_AS');
}
public function postUp(Schema $schema): void
{
if ($this->isMsSql()) {
// The index needs to be re-created in postUp, but here, we can only use statements run against the
// connection directly
$this->connection->executeStatement(
'CREATE INDEX unique_short_code_plus_domain ON short_urls (domain_id, short_code);',
);
}
}
public function down(Schema $schema): void
{
// No down
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
private function isMsSql(): bool
{
return $this->connection->getDatabasePlatform() instanceof SQLServerPlatform;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230211171904 extends AbstractMigration
{
private const INDEX_NAME = 'IDX_visits_potential_bot';
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
$visits->addIndex(['short_url_id', 'potential_bot'], self::INDEX_NAME);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230303164233 extends AbstractMigration
{
private const INDEX_NAME = 'visits_potential_bot_IDX';
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
$visits->dropIndex('IDX_visits_potential_bot'); // Old index
$visits->addIndex(['potential_bot'], self::INDEX_NAME);
}
public function isTransactional(): bool
{
return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}

View File

@@ -1,4 +1,4 @@
log_errors_max_len=0 log_errors_max_len=0
zend.assertions=1 zend.assertions=1
assert.exception=1 assert.exception=1
memory_limit=256M memory_limit=512M

View File

@@ -2,15 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
return [ return [
'migrations_paths' => [ 'migrations_paths' => [
'ShlinkMigrations' => 'data/migrations', 'ShlinkMigrations' => 'data/migrations',
], ],
'table_storage' => [ 'table_storage' => [
'table_name' => MIGRATIONS_TABLE, 'table_name' => 'migrations',
], ],
'custom_template' => 'data/migrations_template.txt', 'custom_template' => 'data/migrations_template.txt',

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI; namespace Shlinkio\Shlink\CLI;
use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader; use GeoIp2\Database\Reader;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\Factory\InvokableFactory;
@@ -116,7 +115,7 @@ return [
LockFactory::class, LockFactory::class,
Util\ProcessRunner::class, Util\ProcessRunner::class,
PhpExecutableFinder::class, PhpExecutableFinder::class,
Connection::class, 'em',
NoDbNameConnectionFactory::SERVICE_NAME, NoDbNameConnectionFactory::SERVICE_NAME,
], ],
Command\Db\MigrateDatabaseCommand::class => [ Command\Db\MigrateDatabaseCommand::class => [

View File

@@ -9,6 +9,7 @@ use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -99,7 +100,7 @@ class GenerateKeyCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString())); $io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) { if (! ApiKey::isAdmin($apiKey)) {
ShlinkTable::default($io)->render( ShlinkTable::default($io)->render(
['Role name', 'Role metadata'], ['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),

View File

@@ -59,7 +59,7 @@ class ListKeysCommand extends Command
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey)); $rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
} }
$rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( $rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (Role $role, array $meta) => fn (Role $role, array $meta) =>
empty($meta) empty($meta)
? $role->toFriendlyName() ? $role->toFriendlyName()

View File

@@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -15,12 +17,13 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use function Functional\contains; use function Functional\contains;
use function Functional\filter; use function Functional\map;
use function Functional\some;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommand extends AbstractDatabaseCommand class CreateDatabaseCommand extends AbstractDatabaseCommand
{ {
private readonly Connection $regularConn;
public const NAME = 'db:create'; public const NAME = 'db:create';
public const DOCTRINE_SCRIPT = 'bin/doctrine'; public const DOCTRINE_SCRIPT = 'bin/doctrine';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create'; public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
@@ -29,9 +32,10 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
LockFactory $locker, LockFactory $locker,
ProcessRunnerInterface $processRunner, ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder, PhpExecutableFinder $phpFinder,
private Connection $regularConn, private readonly EntityManagerInterface $em,
private Connection $noDbNameConn, private readonly Connection $noDbNameConn,
) { ) {
$this->regularConn = $this->em->getConnection();
parent::__construct($locker, $processRunner, $phpFinder); parent::__construct($locker, $processRunner, $phpFinder);
} }
@@ -74,6 +78,8 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// Otherwise, it will fail to connect and will not be able to create the new database // Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->createSchemaManager(); $schemaManager = $this->noDbNameConn->createSchemaManager();
$databases = $schemaManager->listDatabases(); $databases = $schemaManager->listDatabases();
// We cannot use getDatabase() to get the database name here, because then the driver will try to connect, and
// it does not exist yet. We need to read from the raw params instead.
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null; $shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null;
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) { if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
@@ -83,10 +89,14 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function schemaExists(): bool private function schemaExists(): bool
{ {
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// We exclude the migrations table, in case db:migrate was run first by mistake.
// Any other inconsistency will be taken care by the migrations.
$schemaManager = $this->regularConn->createSchemaManager(); $schemaManager = $this->regularConn->createSchemaManager();
return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE)); $existingTables = $schemaManager->listTableNames();
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
$shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName());
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any other inconsistency will be taken care of by the migrations.
return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable));
} }
} }

View File

@@ -161,7 +161,7 @@ class CreateShortUrlCommand extends Command
$doValidateUrl = $input->getOption('validate-url'); $doValidateUrl = $input->getOption('validate-url');
try { try {
$shortUrl = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl, ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
@@ -176,9 +176,14 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
], $this->options)); ], $this->options));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the '
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
));
$io->writeln([ $io->writeln([
sprintf('Processed long URL: <info>%s</info>', $longUrl), sprintf('Processed long URL: <info>%s</info>', $longUrl),
sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)), sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($result->shortUrl)),
]); ]);
return ExitCodes::EXIT_SUCCESS; return ExitCodes::EXIT_SUCCESS;
} catch (InvalidUrlException | NonUniqueSlugException $e) { } catch (InvalidUrlException | NonUniqueSlugException $e) {

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command; namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class GenerateApiKeyTest extends CliTestCase class GenerateApiKeyTest extends CliTestCase
{ {
/** @test */ #[Test]
public function outputIsCorrect(): void public function outputIsCorrect(): void
{ {
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]); [$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);

View File

@@ -5,16 +5,15 @@ declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command; namespace ShlinkioCliTest\Shlink\CLI\Command;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class ListApiKeysTest extends CliTestCase class ListApiKeysTest extends CliTestCase
{ {
/** #[Test, DataProvider('provideFlags')]
* @test
* @dataProvider provideFlags
*/
public function generatesExpectedOutput(array $flags, string $expectedOutput): void public function generatesExpectedOutput(array $flags, string $expectedOutput): void
{ {
[$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]); [$output, $exitCode] = $this->exec([ListKeysCommand::NAME, ...$flags]);
@@ -23,7 +22,7 @@ class ListApiKeysTest extends CliTestCase
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode); self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
} }
public function provideFlags(): iterable public static function provideFlags(): iterable
{ {
$expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString(); $expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString();
$enabledOnlyOutput = <<<OUT $enabledOnlyOutput = <<<OUT

View File

@@ -4,22 +4,21 @@ declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command; namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase; use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class ListShortUrlsTest extends CliTestCase class ListShortUrlsTest extends CliTestCase
{ {
/** #[Test, DataProvider('provideFlagsAndOutput')]
* @test
* @dataProvider provideFlagsAndOutput
*/
public function generatesExpectedOutput(array $flags, string $expectedOutput): void public function generatesExpectedOutput(array $flags, string $expectedOutput): void
{ {
[$output] = $this->exec([ListShortUrlsCommand::NAME, ...$flags], ['no']); [$output] = $this->exec([ListShortUrlsCommand::NAME, ...$flags], ['no']);
self::assertStringContainsString($expectedOutput, $output); self::assertStringContainsString($expectedOutput, $output);
} }
public function provideFlagsAndOutput(): iterable public static function provideFlagsAndOutput(): iterable
{ {
// phpcs:disable Generic.Files.LineLength // phpcs:disable Generic.Files.LineLength
yield 'no flags' => [[], <<<OUTPUT yield 'no flags' => [[], <<<OUTPUT

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\ApiKey; namespace ShlinkioTest\Shlink\CLI\ApiKey;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolver; use Shlinkio\Shlink\CLI\ApiKey\RoleResolver;
@@ -27,29 +29,27 @@ class RoleResolverTest extends TestCase
$this->resolver = new RoleResolver($this->domainService, 'default.com'); $this->resolver = new RoleResolver($this->domainService, 'default.com');
} }
/** #[Test, DataProvider('provideRoles')]
* @test
* @dataProvider provideRoles
*/
public function properRolesAreResolvedBasedOnInput( public function properRolesAreResolvedBasedOnInput(
InputInterface $input, callable $createInput,
array $expectedRoles, array $expectedRoles,
int $expectedDomainCalls, int $expectedDomainCalls,
): void { ): void {
$input = $createInput($this);
$this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with( $this->domainService->expects($this->exactly($expectedDomainCalls))->method('getOrCreate')->with(
'example.com', 'example.com',
)->willReturn($this->domainWithId(Domain::withAuthority('example.com'))); )->willReturn(self::domainWithId(Domain::withAuthority('example.com')));
$result = $this->resolver->determineRoles($input); $result = $this->resolver->determineRoles($input);
self::assertEquals($expectedRoles, $result); self::assertEquals($expectedRoles, $result);
} }
public function provideRoles(): iterable public static function provideRoles(): iterable
{ {
$domain = $this->domainWithId(Domain::withAuthority('example.com')); $domain = self::domainWithId(Domain::withAuthority('example.com'));
$buildInput = function (array $definition): InputInterface { $buildInput = static fn (array $definition) => function (TestCase $test) use ($definition): InputInterface {
$input = $this->createStub(InputInterface::class); $input = $test->createStub(InputInterface::class);
$input->method('getOption')->willReturnMap( $input->method('getOption')->willReturnMap(
map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]), map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]),
); );
@@ -98,7 +98,7 @@ class RoleResolverTest extends TestCase
]; ];
} }
/** @test */ #[Test]
public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void
{ {
$input = $this->createStub(InputInterface::class); $input = $this->createStub(InputInterface::class);
@@ -114,7 +114,7 @@ class RoleResolverTest extends TestCase
$this->resolver->determineRoles($input); $this->resolver->determineRoles($input);
} }
private function domainWithId(Domain $domain): Domain private static function domainWithId(Domain $domain): Domain
{ {
$domain->setId('1'); $domain->setId('1');
return $domain; return $domain;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api; namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand; use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
@@ -25,7 +26,7 @@ class DisableKeyCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService)); $this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService));
} }
/** @test */ #[Test]
public function providedApiKeyIsDisabled(): void public function providedApiKeyIsDisabled(): void
{ {
$apiKey = 'abcd1234'; $apiKey = 'abcd1234';
@@ -39,7 +40,7 @@ class DisableKeyCommandTest extends TestCase
self::assertStringContainsString('API key "abcd1234" properly disabled', $output); self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
} }
/** @test */ #[Test]
public function errorIsReturnedIfServiceThrowsException(): void public function errorIsReturnedIfServiceThrowsException(): void
{ {
$apiKey = 'abcd1234'; $apiKey = 'abcd1234';

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api; namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface; use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
@@ -32,7 +33,7 @@ class GenerateKeyCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function noExpirationDateIsDefinedIfNotProvided(): void public function noExpirationDateIsDefinedIfNotProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(
@@ -46,7 +47,7 @@ class GenerateKeyCommandTest extends TestCase
self::assertStringContainsString('Generated API key: ', $output); self::assertStringContainsString('Generated API key: ', $output);
} }
/** @test */ #[Test]
public function expirationDateIsDefinedIfProvided(): void public function expirationDateIsDefinedIfProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(
@@ -59,7 +60,7 @@ class GenerateKeyCommandTest extends TestCase
]); ]);
} }
/** @test */ #[Test]
public function nameIsDefinedIfProvided(): void public function nameIsDefinedIfProvided(): void
{ {
$this->apiKeyService->expects($this->once())->method('create')->with( $this->apiKeyService->expects($this->once())->method('create')->with(

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api; namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand; use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
@@ -29,10 +31,7 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService)); $this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService));
} }
/** #[Test, DataProvider('provideKeysAndOutputs')]
* @test
* @dataProvider provideKeysAndOutputs
*/
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{ {
$this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys); $this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys);
@@ -43,7 +42,7 @@ class ListKeysCommandTest extends TestCase
self::assertEquals($expected, $output); self::assertEquals($expected, $output);
} }
public function provideKeysAndOutputs(): iterable public static function provideKeysAndOutputs(): iterable
{ {
$dateInThePast = Chronos::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'); $dateInThePast = Chronos::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00');
@@ -84,14 +83,14 @@ class ListKeysCommandTest extends TestCase
yield 'with roles' => [ yield 'with roles' => [
[ [
$apiKey1 = ApiKey::create(), $apiKey1 = ApiKey::create(),
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]), $apiKey2 = self::apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
$apiKey3 = $this->apiKeyWithRoles( $apiKey3 = self::apiKeyWithRoles(
[RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com')))], [RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com')))],
), ),
$apiKey4 = ApiKey::create(), $apiKey4 = ApiKey::create(),
$apiKey5 = $this->apiKeyWithRoles([ $apiKey5 = self::apiKeyWithRoles([
RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com'))), RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com'))),
]), ]),
$apiKey6 = ApiKey::create(), $apiKey6 = ApiKey::create(),
], ],
@@ -141,7 +140,7 @@ class ListKeysCommandTest extends TestCase
]; ];
} }
private function apiKeyWithRoles(array $roles): ApiKey private static function apiKeyWithRoles(array $roles): ApiKey
{ {
$apiKey = ApiKey::create(); $apiKey = ApiKey::create();
foreach ($roles as $role) { foreach ($roles as $role) {
@@ -151,7 +150,7 @@ class ListKeysCommandTest extends TestCase
return $apiKey; return $apiKey;
} }
private function domainWithId(Domain $domain): Domain private static function domainWithId(Domain $domain): Domain
{ {
$domain->setId('1'); $domain->setId('1');
return $domain; return $domain;

View File

@@ -9,6 +9,11 @@ use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\Persistence\Mapping\ClassMetadataFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand; use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
@@ -20,8 +25,6 @@ use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\LockInterface; use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
class CreateDatabaseCommandTest extends TestCase class CreateDatabaseCommandTest extends TestCase
{ {
use CliTestUtilsTrait; use CliTestUtilsTrait;
@@ -29,6 +32,7 @@ class CreateDatabaseCommandTest extends TestCase
private CommandTester $commandTester; private CommandTester $commandTester;
private MockObject & ProcessRunnerInterface $processHelper; private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn; private MockObject & Connection $regularConn;
private MockObject & ClassMetadataFactory $metadataFactory;
private MockObject & AbstractSchemaManager $schemaManager; private MockObject & AbstractSchemaManager $schemaManager;
private MockObject & Driver $driver; private MockObject & Driver $driver;
@@ -49,25 +53,27 @@ class CreateDatabaseCommandTest extends TestCase
$this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager); $this->regularConn->method('createSchemaManager')->willReturn($this->schemaManager);
$this->driver = $this->createMock(Driver::class); $this->driver = $this->createMock(Driver::class);
$this->regularConn->method('getDriver')->willReturn($this->driver); $this->regularConn->method('getDriver')->willReturn($this->driver);
$this->metadataFactory = $this->createMock(ClassMetadataFactory::class);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('getConnection')->willReturn($this->regularConn);
$em->method('getMetadataFactory')->willReturn($this->metadataFactory);
$noDbNameConn = $this->createMock(Connection::class); $noDbNameConn = $this->createMock(Connection::class);
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager); $noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand( $command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
$locker,
$this->processHelper,
$phpExecutableFinder,
$this->regularConn,
$noDbNameConn,
);
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function successMessageIsPrintedIfDatabaseAlreadyExists(): void public function successMessageIsPrintedIfDatabaseAlreadyExists(): void
{ {
$shlinkDatabase = 'shlink_database'; $shlinkDatabase = 'shlink_database';
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$metadataMock = $this->createMock(ClassMetadata::class);
$metadataMock->expects($this->once())->method('getTableName')->willReturn('foo_table');
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadataMock]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
['foo', $shlinkDatabase, 'bar'], ['foo', $shlinkDatabase, 'bar'],
); );
@@ -81,29 +87,30 @@ class CreateDatabaseCommandTest extends TestCase
self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output); self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
} }
/** @test */ #[Test]
public function databaseIsCreatedIfItDoesNotExist(): void public function databaseIsCreatedIfItDoesNotExist(): void
{ {
$shlinkDatabase = 'shlink_database'; $shlinkDatabase = 'shlink_database';
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$this->metadataFactory->method('getAllMetadata')->willReturn([]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']); $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(['foo', 'bar']);
$this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase); $this->schemaManager->expects($this->once())->method('createDatabase')->with($shlinkDatabase);
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn( $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(
['foo_table', 'bar_table', MIGRATIONS_TABLE], ['foo_table', 'bar_table'],
); );
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class)); $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(AbstractPlatform::class));
$this->commandTester->execute([]); $this->commandTester->execute([]);
} }
/** #[Test, DataProvider('provideEmptyDatabase')]
* @test
* @dataProvider provideEmptyDatabase
*/
public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void
{ {
$shlinkDatabase = 'shlink_database'; $shlinkDatabase = 'shlink_database';
$this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]); $this->regularConn->expects($this->once())->method('getParams')->willReturn(['dbname' => $shlinkDatabase]);
$metadata = $this->createMock(ClassMetadata::class);
$metadata->method('getTableName')->willReturn('shlink_table');
$this->metadataFactory->method('getAllMetadata')->willReturn([$metadata]);
$this->schemaManager->expects($this->once())->method('listDatabases')->willReturn( $this->schemaManager->expects($this->once())->method('listDatabases')->willReturn(
['foo', $shlinkDatabase, 'bar'], ['foo', $shlinkDatabase, 'bar'],
); );
@@ -124,18 +131,19 @@ class CreateDatabaseCommandTest extends TestCase
self::assertStringContainsString('Database properly created!', $output); self::assertStringContainsString('Database properly created!', $output);
} }
public function provideEmptyDatabase(): iterable public static function provideEmptyDatabase(): iterable
{ {
yield 'no tables' => [[]]; yield 'no tables' => [[]];
yield 'migrations table' => [[MIGRATIONS_TABLE]]; yield 'migrations table' => [['non_shlink_table']];
} }
/** @test */ #[Test]
public function databaseCheckIsSkippedForSqlite(): void public function databaseCheckIsSkippedForSqlite(): void
{ {
$this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class)); $this->driver->method('getDatabasePlatform')->willReturn($this->createMock(SqlitePlatform::class));
$this->regularConn->expects($this->never())->method('getParams'); $this->regularConn->expects($this->never())->method('getParams');
$this->metadataFactory->expects($this->once())->method('getAllMetadata')->willReturn([]);
$this->schemaManager->expects($this->never())->method('listDatabases'); $this->schemaManager->expects($this->never())->method('listDatabases');
$this->schemaManager->expects($this->never())->method('createDatabase'); $this->schemaManager->expects($this->never())->method('createDatabase');
$this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']); $this->schemaManager->expects($this->once())->method('listTableNames')->willReturn(['foo_table', 'bar_table']);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Db; namespace ShlinkioTest\Shlink\CLI\Command\Db;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand; use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
@@ -38,7 +39,7 @@ class MigrateDatabaseCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function migrationsCommandIsRunWithProperVerbosity(): void public function migrationsCommandIsRunWithProperVerbosity(): void
{ {
$this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [ $this->processHelper->expects($this->once())->method('run')->with($this->isInstanceOf(OutputInterface::class), [

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain; namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand; use Shlinkio\Shlink\CLI\Command\Domain\DomainRedirectsCommand;
@@ -30,10 +32,7 @@ class DomainRedirectsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService)); $this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService));
} }
/** #[Test, DataProvider('provideDomains')]
* @test
* @dataProvider provideDomains
*/
public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void public function onlyPlainQuestionsAreAskedForNewDomainsAndDomainsWithNoRedirects(?Domain $domain): void
{ {
$domainAuthority = 'my-domain.com'; $domainAuthority = 'my-domain.com';
@@ -60,13 +59,13 @@ class DomainRedirectsCommandTest extends TestCase
self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)')); self::assertEquals(3, substr_count($output, '(Leave empty for no redirect)'));
} }
public function provideDomains(): iterable public static function provideDomains(): iterable
{ {
yield 'no domain' => [null]; yield 'no domain' => [null];
yield 'domain without redirects' => [Domain::withAuthority('')]; yield 'domain without redirects' => [Domain::withAuthority('')];
} }
/** @test */ #[Test]
public function offersNewOptionsForDomainsWithExistingRedirects(): void public function offersNewOptionsForDomainsWithExistingRedirects(): void
{ {
$domainAuthority = 'example.com'; $domainAuthority = 'example.com';
@@ -95,7 +94,7 @@ class DomainRedirectsCommandTest extends TestCase
self::assertEquals(3, substr_count($output, 'Remove redirect')); self::assertEquals(3, substr_count($output, 'Remove redirect'));
} }
/** @test */ #[Test]
public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void public function authorityIsRequestedWhenNotProvidedAndNoOtherDomainsExist(): void
{ {
$domainAuthority = 'example.com'; $domainAuthority = 'example.com';
@@ -117,7 +116,7 @@ class DomainRedirectsCommandTest extends TestCase
self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output); self::assertStringContainsString('Domain authority for which you want to set specific redirects', $output);
} }
/** @test */ #[Test]
public function oneOfTheExistingDomainsCanBeSelected(): void public function oneOfTheExistingDomainsCanBeSelected(): void
{ {
$domainAuthority = 'existing-two.com'; $domainAuthority = 'existing-two.com';
@@ -146,7 +145,7 @@ class DomainRedirectsCommandTest extends TestCase
self::assertStringContainsString($domainAuthority, $output); self::assertStringContainsString($domainAuthority, $output);
} }
/** @test */ #[Test]
public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void public function aNewDomainCanBeCreatedEvenIfOthersAlreadyExist(): void
{ {
$domainAuthority = 'new-domain.com'; $domainAuthority = 'new-domain.com';

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain; namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand; use Shlinkio\Shlink\CLI\Command\Domain\GetDomainVisitsCommand;
@@ -37,7 +38,7 @@ class GetDomainVisitsCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain; namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand; use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
@@ -29,10 +31,7 @@ class ListDomainsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService)); $this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService));
} }
/** #[Test, DataProvider('provideInputsAndOutputs')]
* @test
* @dataProvider provideInputsAndOutputs
*/
public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void public function allDomainsAreProperlyPrinted(array $input, string $expectedOutput): void
{ {
$bazDomain = Domain::withAuthority('baz.com'); $bazDomain = Domain::withAuthority('baz.com');
@@ -57,7 +56,7 @@ class ListDomainsCommandTest extends TestCase
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
} }
public function provideInputsAndOutputs(): iterable public static function provideInputsAndOutputs(): iterable
{ {
$withoutRedirectsOutput = <<<OUTPUT $withoutRedirectsOutput = <<<OUTPUT
+---------+------------+ +---------+------------+

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
@@ -15,8 +18,10 @@ use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class CreateShortUrlCommandTest extends TestCase class CreateShortUrlCommandTest extends TestCase
@@ -45,11 +50,13 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn($shortUrl); $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl),
);
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url', 'stringified_short_url',
); );
@@ -57,14 +64,15 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester->execute([ $this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar', 'longUrl' => 'http://domain.com/foo/bar',
'--max-visits' => '3', '--max-visits' => '3',
]); ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output); self::assertStringContainsString('stringified_short_url', $output);
self::assertStringNotContainsString('but the real-time updates cannot', $output);
} }
/** @test */ #[Test]
public function exceptionWhileParsingLongUrlOutputsError(): void public function exceptionWhileParsingLongUrlOutputsError(): void
{ {
$url = 'http://domain.com/invalid'; $url = 'http://domain.com/invalid';
@@ -80,7 +88,7 @@ class CreateShortUrlCommandTest extends TestCase
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
} }
/** @test */ #[Test]
public function providingNonUniqueSlugOutputsError(): void public function providingNonUniqueSlugOutputsError(): void
{ {
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException( $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
@@ -95,7 +103,7 @@ class CreateShortUrlCommandTest extends TestCase
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output); self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
} }
/** @test */ #[Test]
public function properlyProcessesProvidedTags(): void public function properlyProcessesProvidedTags(): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();
@@ -104,7 +112,7 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags); Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
return true; return true;
}), }),
)->willReturn($shortUrl); )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn( $this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url', 'stringified_short_url',
); );
@@ -119,10 +127,7 @@ class CreateShortUrlCommandTest extends TestCase
self::assertStringContainsString('stringified_short_url', $output); self::assertStringContainsString('stringified_short_url', $output);
} }
/** #[Test, DataProvider('provideDomains')]
* @test
* @dataProvider provideDomains
*/
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
{ {
$this->urlShortener->expects($this->once())->method('shorten')->with( $this->urlShortener->expects($this->once())->method('shorten')->with(
@@ -130,7 +135,7 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals($expectedDomain, $meta->domain); Assert::assertEquals($expectedDomain, $meta->domain);
return true; return true;
}), }),
)->willReturn(ShortUrl::createFake()); )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar'; $input['longUrl'] = 'http://domain.com/foo/bar';
@@ -139,7 +144,7 @@ class CreateShortUrlCommandTest extends TestCase
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode()); self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
} }
public function provideDomains(): iterable public static function provideDomains(): iterable
{ {
yield 'no domain' => [[], null]; yield 'no domain' => [[], null];
yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com']; yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
@@ -147,10 +152,7 @@ class CreateShortUrlCommandTest extends TestCase
yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null]; yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null];
} }
/** #[Test, DataProvider('provideFlags')]
* @test
* @dataProvider provideFlags
*/
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();
@@ -159,16 +161,52 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
return true; return true;
}), }),
)->willReturn($shortUrl); )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar'; $options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options); $this->commandTester->execute($options);
} }
public function provideFlags(): iterable public static function provideFlags(): iterable
{ {
yield 'no flags' => [[], null]; yield 'no flags' => [[], null];
yield 'validate-url' => [['--validate-url' => true], true]; yield 'validate-url' => [['--validate-url' => true], true];
} }
/**
* @param callable(string $output): void $assert
*/
#[Test, DataProvider('provideDispatchBehavior')]
public function warningIsPrintedInVerboseModeWhenDispatchErrors(int $verbosity, callable $assert): void
{
$shortUrl = ShortUrl::createFake();
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willReturn(
UrlShorteningResult::withErrorOnEventDispatching($shortUrl, new ServiceNotFoundException()),
);
$this->stringifier->method('stringify')->willReturn('stringified_short_url');
$this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay();
$assert($output);
}
public static function provideDispatchBehavior(): iterable
{
$containsAssertion = static fn (string $output) => self::assertStringContainsString(
'but the real-time updates cannot',
$output,
);
$doesNotContainAssertion = static fn (string $output) => self::assertStringNotContainsString(
'but the real-time updates cannot',
$output,
);
yield 'quiet' => [OutputInterface::VERBOSITY_QUIET, $doesNotContainAssertion];
yield 'normal' => [OutputInterface::VERBOSITY_NORMAL, $doesNotContainAssertion];
yield 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, $containsAssertion];
yield 'very verbose' => [OutputInterface::VERBOSITY_VERY_VERBOSE, $containsAssertion];
yield 'debug' => [OutputInterface::VERBOSITY_DEBUG, $containsAssertion];
}
} }

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
@@ -30,7 +32,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service)); $this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service));
} }
/** @test */ #[Test]
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -48,7 +50,7 @@ class DeleteShortUrlCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function invalidShortCodePrintsMessage(): void public function invalidShortCodePrintsMessage(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -64,10 +66,7 @@ class DeleteShortUrlCommandTest extends TestCase
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output); self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
} }
/** #[Test, DataProvider('provideRetryDeleteAnswers')]
* @test
* @dataProvider provideRetryDeleteAnswers
*/
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted( public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer, array $retryAnswer,
int $expectedDeleteCalls, int $expectedDeleteCalls,
@@ -98,14 +97,14 @@ class DeleteShortUrlCommandTest extends TestCase
self::assertStringContainsString($expectedMessage, $output); self::assertStringContainsString($expectedMessage, $output);
} }
public function provideRetryDeleteAnswers(): iterable public static function provideRetryDeleteAnswers(): iterable
{ {
yield 'answering yes to retry' => [['yes'], 2, 'Short URL with short code "abc123" successfully deleted.']; yield 'answering yes to retry' => [['yes'], 2, 'Short URL with short code "abc123" successfully deleted.'];
yield 'answering no to retry' => [['no'], 1, 'Short URL was not deleted.']; yield 'answering no to retry' => [['no'], 1, 'Short URL was not deleted.'];
yield 'answering default to retry' => [[PHP_EOL], 1, 'Short URL was not deleted.']; yield 'answering default to retry' => [[PHP_EOL], 1, 'Short URL was not deleted.'];
} }
/** @test */ #[Test]
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
@@ -39,7 +40,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function noDateFlagsTriesToListWithoutDateRange(): void public function noDateFlagsTriesToListWithoutDateRange(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -51,7 +52,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]); $this->commandTester->execute(['shortCode' => $shortCode]);
} }
/** @test */ #[Test]
public function providingDateFlagsTheListGetsFiltered(): void public function providingDateFlagsTheListGetsFiltered(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -69,7 +70,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
]); ]);
} }
/** @test */ #[Test]
public function providingInvalidDatesPrintsWarning(): void public function providingInvalidDatesPrintsWarning(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -91,7 +92,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('bar', 'foo', '', ''))->locate(

View File

@@ -6,6 +6,8 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
@@ -41,13 +43,13 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command); $this->commandTester = $this->testerForCommand($command);
} }
/** @test */ #[Test]
public function loadingMorePagesCallsListMoreTimes(): void public function loadingMorePagesCallsListMoreTimes(): void
{ {
// The paginator will return more than one page // The paginator will return more than one page
$data = []; $data = [];
for ($i = 0; $i < 50; $i++) { for ($i = 0; $i < 50; $i++) {
$data[] = ShortUrl::withLongUrl('url_' . $i); $data[] = ShortUrl::withLongUrl('https://url_' . $i);
} }
$this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters() $this->shortUrlService->expects($this->exactly(3))->method('listShortUrls')->withAnyParameters()
@@ -63,13 +65,13 @@ class ListShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Continue with page 5?', $output); self::assertStringNotContainsString('Continue with page 5?', $output);
} }
/** @test */ #[Test]
public function havingMorePagesButAnsweringNoCallsListJustOnce(): void public function havingMorePagesButAnsweringNoCallsListJustOnce(): void
{ {
// The paginator will return more than one page // The paginator will return more than one page
$data = []; $data = [];
for ($i = 0; $i < 30; $i++) { for ($i = 0; $i < 30; $i++) {
$data[] = ShortUrl::withLongUrl('url_' . $i); $data[] = ShortUrl::withLongUrl('https://url_' . $i);
} }
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with( $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(
@@ -89,7 +91,7 @@ class ListShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Continue with page 3?', $output); self::assertStringNotContainsString('Continue with page 3?', $output);
} }
/** @test */ #[Test]
public function passingPageWillMakeListStartOnThatPage(): void public function passingPageWillMakeListStartOnThatPage(): void
{ {
$page = 5; $page = 5;
@@ -101,10 +103,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute(['--page' => $page]); $this->commandTester->execute(['--page' => $page]);
} }
/** #[Test, DataProvider('provideOptionalFlags')]
* @test
* @dataProvider provideOptionalFlags
*/
public function provideOptionalFlagsMakesNewColumnsToBeIncluded( public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
array $input, array $input,
array $expectedContents, array $expectedContents,
@@ -115,7 +114,7 @@ class ListShortUrlsCommandTest extends TestCase
ShortUrlsParams::emptyInstance(), ShortUrlsParams::emptyInstance(),
)->willReturn(new Paginator(new ArrayAdapter([ )->willReturn(new Paginator(new ArrayAdapter([
ShortUrl::create(ShortUrlCreation::fromRawData([ ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo.com', 'longUrl' => 'https://foo.com',
'tags' => ['foo', 'bar', 'baz'], 'tags' => ['foo', 'bar', 'baz'],
'apiKey' => $apiKey, 'apiKey' => $apiKey,
])), ])),
@@ -137,7 +136,7 @@ class ListShortUrlsCommandTest extends TestCase
} }
} }
public function provideOptionalFlags(): iterable public static function provideOptionalFlags(): iterable
{ {
$apiKey = ApiKey::fromMeta(ApiKeyMeta::withName('my api key')); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withName('my api key'));
$key = $apiKey->toString(); $key = $apiKey->toString();
@@ -174,10 +173,7 @@ class ListShortUrlsCommandTest extends TestCase
]; ];
} }
/** #[Test, DataProvider('provideArgs')]
* @test
* @dataProvider provideArgs
*/
public function serviceIsInvokedWithProvidedArgs( public function serviceIsInvokedWithProvidedArgs(
array $commandArgs, array $commandArgs,
?int $page, ?int $page,
@@ -200,7 +196,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute($commandArgs); $this->commandTester->execute($commandArgs);
} }
public function provideArgs(): iterable public static function provideArgs(): iterable
{ {
yield [[], 1, null, [], TagsMode::ANY->value]; yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
@@ -241,10 +237,7 @@ class ListShortUrlsCommandTest extends TestCase
]; ];
} }
/** #[Test, DataProvider('provideOrderBy')]
* @test
* @dataProvider provideOrderBy
*/
public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
{ {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
@@ -255,7 +248,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute($commandArgs); $this->commandTester->execute($commandArgs);
} }
public function provideOrderBy(): iterable public static function provideOrderBy(): iterable
{ {
yield [[], null]; yield [[], null];
yield [['--order-by' => 'visits'], 'visits']; yield [['--order-by' => 'visits'], 'visits'];
@@ -264,7 +257,7 @@ class ListShortUrlsCommandTest extends TestCase
yield [['--order-by' => 'title-DESC'], 'title-DESC']; yield [['--order-by' => 'title-DESC'], 'title-DESC'];
} }
/** @test */ #[Test]
public function requestingAllElementsWillSetItemsPerPage(): void public function requestingAllElementsWillSetItemsPerPage(): void
{ {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ $this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl; namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
@@ -31,7 +32,7 @@ class ResolveUrlCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver)); $this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver));
} }
/** @test */ #[Test]
public function correctShortCodeResolvesUrl(): void public function correctShortCodeResolvesUrl(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -46,7 +47,7 @@ class ResolveUrlCommandTest extends TestCase
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output); self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
} }
/** @test */ #[Test]
public function incorrectShortCodeOutputsErrorMessage(): void public function incorrectShortCodeOutputsErrorMessage(): void
{ {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123'); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123');

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
@@ -24,7 +25,7 @@ class DeleteTagsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService)); $this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService));
} }
/** @test */ #[Test]
public function errorIsReturnedWhenNoTagsAreProvided(): void public function errorIsReturnedWhenNoTagsAreProvided(): void
{ {
$this->commandTester->execute([]); $this->commandTester->execute([]);
@@ -33,7 +34,7 @@ class DeleteTagsCommandTest extends TestCase
self::assertStringContainsString('You have to provide at least one tag name', $output); self::assertStringContainsString('You have to provide at least one tag name', $output);
} }
/** @test */ #[Test]
public function serviceIsInvokedOnSuccess(): void public function serviceIsInvokedOnSuccess(): void
{ {
$tagNames = ['foo', 'bar']; $tagNames = ['foo', 'bar'];

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand; use Shlinkio\Shlink\CLI\Command\Tag\GetTagVisitsCommand;
@@ -37,7 +38,7 @@ class GetTagVisitsCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
@@ -27,7 +28,7 @@ class ListTagsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService)); $this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService));
} }
/** @test */ #[Test]
public function noTagsPrintsEmptyMessage(): void public function noTagsPrintsEmptyMessage(): void
{ {
$this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn( $this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn(
@@ -40,7 +41,7 @@ class ListTagsCommandTest extends TestCase
self::assertStringContainsString('No tags found', $output); self::assertStringContainsString('No tags found', $output);
} }
/** @test */ #[Test]
public function listOfTagsIsPrinted(): void public function listOfTagsIsPrinted(): void
{ {
$this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn( $this->tagService->expects($this->once())->method('tagsInfo')->withAnyParameters()->willReturn(

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag; namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand; use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
@@ -27,7 +28,7 @@ class RenameTagCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService)); $this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService));
} }
/** @test */ #[Test]
public function errorIsPrintedIfExceptionIsThrown(): void public function errorIsPrintedIfExceptionIsThrown(): void
{ {
$oldName = 'foo'; $oldName = 'foo';
@@ -45,7 +46,7 @@ class RenameTagCommandTest extends TestCase
self::assertStringContainsString('Tag with name "foo" could not be found', $output); self::assertStringContainsString('Tag with name "foo" could not be found', $output);
} }
/** @test */ #[Test]
public function successIsPrintedIfNoErrorOccurs(): void public function successIsPrintedIfNoErrorOccurs(): void
{ {
$oldName = 'foo'; $oldName = 'foo';

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit; namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
@@ -29,10 +31,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater)); $this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater));
} }
/** #[Test, DataProvider('provideFailureParams')]
* @test
* @dataProvider provideFailureParams
*/
public function showsProperMessageWhenGeoLiteUpdateFails( public function showsProperMessageWhenGeoLiteUpdateFails(
bool $olderDbExists, bool $olderDbExists,
string $expectedMessage, string $expectedMessage,
@@ -61,7 +60,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
self::assertSame($expectedExitCode, $exitCode); self::assertSame($expectedExitCode, $exitCode);
} }
public function provideFailureParams(): iterable public static function provideFailureParams(): iterable
{ {
yield 'existing db' => [ yield 'existing db' => [
true, true,
@@ -75,10 +74,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
]; ];
} }
/** #[Test, DataProvider('provideSuccessParams')]
* @test
* @dataProvider provideSuccessParams
*/
public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void
{ {
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback( $this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturnCallback(
@@ -93,7 +89,7 @@ class DownloadGeoLiteDbCommandTest extends TestCase
self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode); self::assertSame(ExitCodes::EXIT_SUCCESS, $exitCode);
} }
public function provideSuccessParams(): iterable public static function provideSuccessParams(): iterable
{ {
yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.']; yield 'up to date db' => [fn () => GeolocationResult::CHECK_SKIPPED, '[INFO] GeoLite2 db file is up to date.'];
yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult { yield 'outdated db' => [function (callable $beforeDownload): GeolocationResult {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit; namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand; use Shlinkio\Shlink\CLI\Command\Visit\GetNonOrphanVisitsCommand;
@@ -37,7 +38,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit; namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use Pagerfanta\Adapter\ArrayAdapter; use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand; use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand;
@@ -30,7 +31,7 @@ class GetOrphanVisitsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper)); $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper));
} }
/** @test */ #[Test]
public function outputIsProperlyGenerated(): void public function outputIsProperlyGenerated(): void
{ {
$visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate(

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Visit; namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand; use Shlinkio\Shlink\CLI\Command\Visit\DownloadGeoLiteDbCommand;
@@ -55,10 +57,7 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand); $this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand);
} }
/** #[Test, DataProvider('provideArgs')]
* @test
* @dataProvider provideArgs
*/
public function expectedSetOfVisitsIsProcessedBasedOnArgs( public function expectedSetOfVisitsIsProcessedBasedOnArgs(
int $expectedUnlocatedCalls, int $expectedUnlocatedCalls,
int $expectedEmptyCalls, int $expectedEmptyCalls,
@@ -100,17 +99,14 @@ class LocateVisitsCommandTest extends TestCase
} }
} }
public function provideArgs(): iterable public static function provideArgs(): iterable
{ {
yield 'no args' => [1, 0, 0, false, []]; yield 'no args' => [1, 0, 0, false, []];
yield 'retry' => [1, 1, 0, false, ['--retry' => true]]; yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]]; yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
} }
/** #[Test, DataProvider('provideIgnoredAddresses')]
* @test
* @dataProvider provideIgnoredAddresses
*/
public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void public function localhostAndEmptyAddressesAreIgnored(IpCannotBeLocatedException $e, string $message): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance());
@@ -131,13 +127,13 @@ class LocateVisitsCommandTest extends TestCase
self::assertStringContainsString($message, $output); self::assertStringContainsString($message, $output);
} }
public function provideIgnoredAddresses(): iterable public static function provideIgnoredAddresses(): iterable
{ {
yield 'empty address' => [IpCannotBeLocatedException::forEmptyAddress(), 'Ignored visit with no IP address']; yield 'empty address' => [IpCannotBeLocatedException::forEmptyAddress(), 'Ignored visit with no IP address'];
yield 'localhost address' => [IpCannotBeLocatedException::forLocalhost(), 'Ignored localhost address']; yield 'localhost address' => [IpCannotBeLocatedException::forLocalhost(), 'Ignored localhost address'];
} }
/** @test */ #[Test]
public function errorWhileLocatingIpIsDisplayed(): void public function errorWhileLocatingIpIsDisplayed(): void
{ {
$visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', '')); $visit = Visit::forValidShortUrl(ShortUrl::createFake(), new Visitor('', '', '1.2.3.4', ''));
@@ -168,7 +164,7 @@ class LocateVisitsCommandTest extends TestCase
}; };
} }
/** @test */ #[Test]
public function noActionIsPerformedIfLockIsAcquired(): void public function noActionIsPerformedIfLockIsAcquired(): void
{ {
$this->lock->method('acquire')->with($this->isFalse())->willReturn(false); $this->lock->method('acquire')->with($this->isFalse())->willReturn(false);
@@ -186,7 +182,7 @@ class LocateVisitsCommandTest extends TestCase
); );
} }
/** @test */ #[Test]
public function showsProperMessageWhenGeoLiteUpdateFails(): void public function showsProperMessageWhenGeoLiteUpdateFails(): void
{ {
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
@@ -199,7 +195,7 @@ class LocateVisitsCommandTest extends TestCase
self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output); self::assertStringContainsString('It is not possible to locate visits without a GeoLite2 db file.', $output);
} }
/** @test */ #[Test]
public function providingAllFlagOnItsOwnDisplaysNotice(): void public function providingAllFlagOnItsOwnDisplaysNotice(): void
{ {
$this->lock->method('acquire')->with($this->isFalse())->willReturn(true); $this->lock->method('acquire')->with($this->isFalse())->willReturn(true);
@@ -211,10 +207,7 @@ class LocateVisitsCommandTest extends TestCase
self::assertStringContainsString('The --all flag has no effect on its own', $output); self::assertStringContainsString('The --all flag has no effect on its own', $output);
} }
/** #[Test, DataProvider('provideAbortInputs')]
* @test
* @dataProvider provideAbortInputs
*/
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
{ {
$this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS); $this->downloadDbCommand->method('run')->withAnyParameters()->willReturn(ExitCodes::EXIT_SUCCESS);
@@ -226,7 +219,7 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute(['--all' => true, '--retry' => true]); $this->commandTester->execute(['--all' => true, '--retry' => true]);
} }
public function provideAbortInputs(): iterable public static function provideAbortInputs(): iterable
{ {
yield 'n' => [['n']]; yield 'n' => [['n']];
yield 'no' => [['no']]; yield 'no' => [['no']];

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI; namespace ShlinkioTest\Shlink\CLI;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ConfigProvider; use Shlinkio\Shlink\CLI\ConfigProvider;
@@ -17,7 +18,7 @@ class ConfigProviderTest extends TestCase
$this->configProvider = new ConfigProvider(); $this->configProvider = new ConfigProvider();
} }
/** @test */ #[Test]
public function configIsProperlyReturned(): void public function configIsProperlyReturned(): void
{ {
$config = ($this->configProvider)(); $config = ($this->configProvider)();

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception; namespace ShlinkioTest\Shlink\CLI\Exception;
use Exception; use Exception;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use RuntimeException; use RuntimeException;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
@@ -12,10 +14,7 @@ use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase class GeolocationDbUpdateFailedExceptionTest extends TestCase
{ {
/** #[Test, DataProvider('providePrev')]
* @test
* @dataProvider providePrev
*/
public function withOlderDbBuildsException(?Throwable $prev): void public function withOlderDbBuildsException(?Throwable $prev): void
{ {
$e = GeolocationDbUpdateFailedException::withOlderDb($prev); $e = GeolocationDbUpdateFailedException::withOlderDb($prev);
@@ -29,10 +28,7 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
self::assertEquals($prev, $e->getPrevious()); self::assertEquals($prev, $e->getPrevious());
} }
/** #[Test, DataProvider('providePrev')]
* @test
* @dataProvider providePrev
*/
public function withoutOlderDbBuildsException(?Throwable $prev): void public function withoutOlderDbBuildsException(?Throwable $prev): void
{ {
$e = GeolocationDbUpdateFailedException::withoutOlderDb($prev); $e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
@@ -46,14 +42,14 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
self::assertEquals($prev, $e->getPrevious()); self::assertEquals($prev, $e->getPrevious());
} }
public function providePrev(): iterable public static function providePrev(): iterable
{ {
yield 'no prev' => [null]; yield 'no prev' => [null];
yield 'RuntimeException' => [new RuntimeException('prev')]; yield 'RuntimeException' => [new RuntimeException('prev')];
yield 'Exception' => [new Exception('prev')]; yield 'Exception' => [new Exception('prev')];
} }
/** @test */ #[Test]
public function withInvalidEpochInOldDbBuildsException(): void public function withInvalidEpochInOldDbBuildsException(): void
{ {
$e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar'); $e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Exception; namespace ShlinkioTest\Shlink\CLI\Exception;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
@@ -12,7 +13,7 @@ use function sprintf;
class InvalidRoleConfigExceptionTest extends TestCase class InvalidRoleConfigExceptionTest extends TestCase
{ {
/** @test */ #[Test]
public function forDomainOnlyWithDefaultDomainGeneratesExpectedException(): void public function forDomainOnlyWithDefaultDomainGeneratesExpectedException(): void
{ {
$e = InvalidRoleConfigException::forDomainOnlyWithDefaultDomain(); $e = InvalidRoleConfigException::forDomainOnlyWithDefaultDomain();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Factory; namespace ShlinkioTest\Shlink\CLI\Factory;
use Laminas\ServiceManager\ServiceManager; use Laminas\ServiceManager\ServiceManager;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory; use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
@@ -21,7 +22,7 @@ class ApplicationFactoryTest extends TestCase
$this->factory = new ApplicationFactory(); $this->factory = new ApplicationFactory();
} }
/** @test */ #[Test]
public function allCommandsWhichAreServicesAreAdded(): void public function allCommandsWhichAreServicesAreAdded(): void
{ {
$sm = $this->createServiceManager([ $sm = $this->createServiceManager([

View File

@@ -7,6 +7,8 @@ namespace ShlinkioTest\Shlink\CLI\GeoLite;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use GeoIp2\Database\Reader; use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata; use MaxMind\Db\Reader\Metadata;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException; use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
@@ -35,7 +37,7 @@ class GeolocationDbUpdaterTest extends TestCase
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true); $this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
} }
/** @test */ #[Test]
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{ {
$mustBeUpdated = fn () => self::assertTrue(true); $mustBeUpdated = fn () => self::assertTrue(true);
@@ -58,10 +60,7 @@ class GeolocationDbUpdaterTest extends TestCase
} }
} }
/** #[Test, DataProvider('provideBigDays')]
* @test
* @dataProvider provideBigDays
*/
public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
{ {
$prev = new DbUpdateException(''); $prev = new DbUpdateException('');
@@ -84,7 +83,7 @@ class GeolocationDbUpdaterTest extends TestCase
} }
} }
public function provideBigDays(): iterable public static function provideBigDays(): iterable
{ {
yield [36]; yield [36];
yield [50]; yield [50];
@@ -92,10 +91,7 @@ class GeolocationDbUpdaterTest extends TestCase
yield [100]; yield [100];
} }
/** #[Test, DataProvider('provideSmallDays')]
* @test
* @dataProvider provideSmallDays
*/
public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void public function databaseIsNotUpdatedIfItIsNewEnough(string|int $buildEpoch): void
{ {
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
@@ -109,7 +105,7 @@ class GeolocationDbUpdaterTest extends TestCase
self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result); self::assertEquals(GeolocationResult::DB_IS_UP_TO_DATE, $result);
} }
public function provideSmallDays(): iterable public static function provideSmallDays(): iterable
{ {
$generateParamsWithTimestamp = static function (int $days) { $generateParamsWithTimestamp = static function (int $days) {
$timestamp = Chronos::now()->subDays($days)->getTimestamp(); $timestamp = Chronos::now()->subDays($days)->getTimestamp();
@@ -119,7 +115,7 @@ class GeolocationDbUpdaterTest extends TestCase
return map(range(0, 34), $generateParamsWithTimestamp); return map(range(0, 34), $generateParamsWithTimestamp);
} }
/** @test */ #[Test]
public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
{ {
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true); $this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(true);
@@ -151,10 +147,7 @@ class GeolocationDbUpdaterTest extends TestCase
]); ]);
} }
/** #[Test, DataProvider('provideTrackingOptions')]
* @test
* @dataProvider provideTrackingOptions
*/
public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void public function downloadDbIsSkippedIfTrackingIsDisabled(TrackingOptions $options): void
{ {
$result = $this->geolocationDbUpdater($options)->checkDbUpdate(); $result = $this->geolocationDbUpdater($options)->checkDbUpdate();
@@ -164,7 +157,7 @@ class GeolocationDbUpdaterTest extends TestCase
self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result); self::assertEquals(GeolocationResult::CHECK_SKIPPED, $result);
} }
public function provideTrackingOptions(): iterable public static function provideTrackingOptions(): iterable
{ {
yield 'disableTracking' => [new TrackingOptions(disableTracking: true)]; yield 'disableTracking' => [new TrackingOptions(disableTracking: true)];
yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)]; yield 'disableIpTracking' => [new TrackingOptions(disableIpTracking: true)];

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util; namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Util\ProcessRunner; use Shlinkio\Shlink\CLI\Util\ProcessRunner;
@@ -34,7 +35,7 @@ class ProcessRunnerTest extends TestCase
$this->runner = new ProcessRunner($this->helper, fn () => $this->process); $this->runner = new ProcessRunner($this->helper, fn () => $this->process);
} }
/** @test */ #[Test]
public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void
{ {
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false); $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false);
@@ -50,7 +51,7 @@ class ProcessRunnerTest extends TestCase
$this->runner->run($this->output, []); $this->runner->run($this->output, []);
} }
/** @test */ #[Test]
public function someMessagesAreWrittenWhenOutputIsVerbose(): void public function someMessagesAreWrittenWhenOutputIsVerbose(): void
{ {
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(true); $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(true);
@@ -66,7 +67,7 @@ class ProcessRunnerTest extends TestCase
$this->runner->run($this->output, []); $this->runner->run($this->output, []);
} }
/** @test */ #[Test]
public function wrapsCallbackWhenOutputIsDebug(): void public function wrapsCallbackWhenOutputIsDebug(): void
{ {
$this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false); $this->output->expects($this->exactly(2))->method('isVeryVerbose')->with()->willReturn(false);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Util; namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionObject; use ReflectionObject;
@@ -23,7 +24,7 @@ class ShlinkTableTest extends TestCase
$this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable); $this->shlinkTable = ShlinkTable::fromBaseTable($this->baseTable);
} }
/** @test */ #[Test]
public function renderMakesTableToBeRenderedWithProvidedInfo(): void public function renderMakesTableToBeRenderedWithProvidedInfo(): void
{ {
$headers = []; $headers = [];
@@ -43,7 +44,7 @@ class ShlinkTableTest extends TestCase
$this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle); $this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle);
} }
/** @test */ #[Test]
public function newTableIsCreatedForFactoryMethod(): void public function newTableIsCreatedForFactoryMethod(): void
{ {
$instance = ShlinkTable::default($this->createMock(OutputInterface::class)); $instance = ShlinkTable::default($this->createMock(OutputInterface::class));

View File

@@ -187,7 +187,7 @@ return [
Util\DoctrineBatchHelper::class, Util\DoctrineBatchHelper::class,
], ],
Crawling\CrawlingHelper::class => ['em'], Crawling\CrawlingHelper::class => [ShortUrl\Repository\CrawlableShortCodesQuery::class],
], ],
]; ];

View File

@@ -18,7 +18,6 @@ use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Importer\Model\ImportResult; use Shlinkio\Shlink\Importer\Model\ImportResult;
use Shlinkio\Shlink\Importer\Params\ImportParams; use Shlinkio\Shlink\Importer\Params\ImportParams;
use Shlinkio\Shlink\Importer\Sources\ImportSource;
use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\StyleInterface;
use Throwable; use Throwable;
@@ -55,8 +54,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
private function importShortUrls(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void private function importShortUrls(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void
{ {
$importShortCodes = $params->importShortCodes; $importShortCodes = $params->importShortCodes;
$source = $params->source; $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $params->importVisits ? 10 : 100);
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100);
foreach ($iterable as $importedUrl) { foreach ($iterable as $importedUrl) {
$skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf( $skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf(
@@ -82,7 +80,10 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
continue; continue;
} }
$resultMessage = $shortUrlImporting->importVisits($importedUrl->visits, $this->em); $resultMessage = $shortUrlImporting->importVisits(
$this->batchHelper->wrapIterable($importedUrl->visits, 100),
$this->em,
);
$io->text(sprintf('%s: %s', $longUrl, $resultMessage)); $io->text(sprintf('%s: %s', $longUrl, $resultMessage));
} }
} }

View File

@@ -33,7 +33,7 @@ final class ShortUrlImporting
*/ */
public function importVisits(iterable $visits, EntityManagerInterface $em): string public function importVisits(iterable $visits, EntityManagerInterface $em): string
{ {
$mostRecentImportedDate = $this->shortUrl->mostRecentImportedVisitDate(); $mostRecentImportedDate = $this->resolveShortUrl($em)->mostRecentImportedVisitDate();
$importedVisits = 0; $importedVisits = 0;
foreach ($visits as $importedVisit) { foreach ($visits as $importedVisit) {
@@ -42,7 +42,7 @@ final class ShortUrlImporting
continue; continue;
} }
$em->persist(Visit::fromImport($this->shortUrl, $importedVisit)); $em->persist(Visit::fromImport($this->resolveShortUrl($em), $importedVisit));
$importedVisits++; $importedVisits++;
} }
@@ -54,4 +54,14 @@ final class ShortUrlImporting
? sprintf('<info>Imported</info> with <info>%s</info> visits', $importedVisits) ? sprintf('<info>Imported</info> with <info>%s</info> visits', $importedVisits)
: sprintf('<comment>Skipped</comment>. Imported <info>%s</info> visits', $importedVisits); : sprintf('<comment>Skipped</comment>. Imported <info>%s</info> visits', $importedVisits);
} }
private function resolveShortUrl(EntityManagerInterface $em): ShortUrl
{
// Instead of directly accessing wrapped ShortUrl entity, try to get it from the EM.
// With this, we will get the same entity from memory if it is known by the EM, but if it was cleared, the EM
// will fetch it again from the database, preventing errors at runtime.
// However, if the EM was not flushed yet, the entity will not be found by ID, but it is known by the EM.
// In that case, we fall back to wrapped ShortUrl entity directly.
return $em->find(ShortUrl::class, $this->shortUrl->getId()) ?? $this->shortUrl;
}
} }

View File

@@ -22,8 +22,8 @@ final class UrlShortenerOptions
) { ) {
} }
public function isLooselyMode(): bool public function isLooseMode(): bool
{ {
return $this->mode === ShortUrlMode::LOOSELY; return $this->mode === ShortUrlMode::LOOSE;
} }
} }

View File

@@ -39,8 +39,8 @@ class ShortUrl extends AbstractEntity
private string $longUrl; private string $longUrl;
private string $shortCode; private string $shortCode;
private Chronos $dateCreated; private Chronos $dateCreated;
/** @var Collection<int, Visit> */ /** @var Collection<int, Visit> & Selectable */
private Collection $visits; private Collection & Selectable $visits;
/** @var Collection<string, DeviceLongUrl> */ /** @var Collection<string, DeviceLongUrl> */
private Collection $deviceLongUrls; private Collection $deviceLongUrls;
/** @var Collection<int, Tag> */ /** @var Collection<int, Tag> */
@@ -68,7 +68,7 @@ class ShortUrl extends AbstractEntity
*/ */
public static function createFake(): self public static function createFake(): self
{ {
return self::withLongUrl('foo'); return self::withLongUrl('https://foo');
} }
/** /**
@@ -255,23 +255,19 @@ class ShortUrl extends AbstractEntity
public function mostRecentImportedVisitDate(): ?Chronos public function mostRecentImportedVisitDate(): ?Chronos
{ {
/** @var Selectable $visits */
$visits = $this->visits;
$criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED)) $criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED))
->orderBy(['id' => 'DESC']) ->orderBy(['id' => 'DESC'])
->setMaxResults(1); ->setMaxResults(1);
$visit = $this->visits->matching($criteria)->last();
/** @var Visit|false $visit */ return $visit instanceof Visit ? $visit->getDate() : null;
$visit = $visits->matching($criteria)->last();
return $visit === false ? null : $visit->getDate();
} }
/** /**
* @param Collection<int, Visit> $visits * @param Collection<int, Visit> & Selectable $visits
* @internal * @internal
*/ */
public function setVisits(Collection $visits): self public function setVisits(Collection & Selectable $visits): self
{ {
$this->visits = $visits; $this->visits = $visits;
return $this; return $this;

View File

@@ -5,5 +5,11 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
enum ShortUrlMode: string enum ShortUrlMode: string
{ {
case STRICT = 'strict'; case STRICT = 'strict';
case LOOSELY = 'loosely'; case LOOSE = 'loose';
/** @deprecated */
public static function tryDeprecated(string $mode): ?self
{
return $mode === 'loosely' ? self::LOOSE : self::tryFrom($mode);
}
} }

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Model;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Throwable;
final class UrlShorteningResult
{
private function __construct(
public readonly ShortUrl $shortUrl,
private readonly ?Throwable $errorOnEventDispatching,
) {
}
/**
* @param callable(Throwable $errorOnEventDispatching): mixed $handler
*/
public function onEventDispatchingError(callable $handler): void
{
if ($this->errorOnEventDispatching !== null) {
$handler($this->errorOnEventDispatching);
}
}
public static function withoutErrorOnEventDispatching(ShortUrl $shortUrl): self
{
return new self($shortUrl, null);
}
public static function withErrorOnEventDispatching(ShortUrl $shortUrl, Throwable $errorOnEventDispatching): self
{
return new self($shortUrl, $errorOnEventDispatching);
}
}

View File

@@ -24,7 +24,7 @@ class CustomSlugFilter implements FilterInterface
return $value; return $value;
} }
$value = $this->options->isLooselyMode() ? strtolower($value) : $value; $value = $this->options->isLooseMode() ? strtolower($value) : $value;
return (match ($this->options->multiSegmentSlugsEnabled) { return (match ($this->options->multiSegmentSlugsEnabled) {
true => trim(str_replace(' ', '-', $value), '/'), true => trim(str_replace(' ', '-', $value), '/'),
false => str_replace([' ', '/'], '-', $value), false => str_replace([' ', '/'], '-', $value),

View File

@@ -12,8 +12,11 @@ use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function is_string;
use function preg_match;
use function substr; use function substr;
use const Shlinkio\Shlink\LOOSE_URI_MATCHER;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
/** /**
@@ -59,27 +62,13 @@ class ShortUrlInputFilter extends InputFilter
private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void
{ {
$longUrlNotEmptyCommonOptions = [
Validator\NotEmpty::OBJECT,
Validator\NotEmpty::SPACE,
Validator\NotEmpty::EMPTY_ARRAY,
Validator\NotEmpty::BOOLEAN,
Validator\NotEmpty::STRING,
];
$longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl);
$longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ $longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
...$longUrlNotEmptyCommonOptions,
Validator\NotEmpty::NULL,
]));
$this->add($longUrlInput); $this->add($longUrlInput);
$deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false);
$deviceLongUrlsInput->getValidatorChain()->attach( $deviceLongUrlsInput->getValidatorChain()->attach(
new DeviceLongUrlsValidator(new Validator\NotEmpty([ new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)),
...$longUrlNotEmptyCommonOptions,
...($requireLongUrl ? [Validator\NotEmpty::NULL] : []),
])),
); );
$this->add($deviceLongUrlsInput); $this->add($deviceLongUrlsInput);
@@ -129,4 +118,25 @@ class ShortUrlInputFilter extends InputFilter
$this->add($this->createBooleanInput(self::CRAWLABLE, false)); $this->add($this->createBooleanInput(self::CRAWLABLE, false));
} }
private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
{
$emptyModifiers = [
Validator\NotEmpty::OBJECT,
Validator\NotEmpty::SPACE,
Validator\NotEmpty::EMPTY_ARRAY,
Validator\NotEmpty::BOOLEAN,
Validator\NotEmpty::STRING,
];
if (! $allowNull) {
$emptyModifiers[] = Validator\NotEmpty::NULL;
}
return (new Validator\ValidatorChain())
->attach(new Validator\NotEmpty($emptyModifiers))
->attach(new Validator\Callback(
// Non-strings is always allowed. Other validators will take care of those
static fn (mixed $value) => ! is_string($value) || preg_match(LOOSE_URI_MATCHER, $value) === 1,
));
}
} }

View File

@@ -20,7 +20,8 @@ class CrawlableShortCodesQuery extends EntitySpecificationRepository implements
->from(ShortUrl::class, 's') ->from(ShortUrl::class, 's')
->where($qb->expr()->eq('s.crawlable', ':crawlable')) ->where($qb->expr()->eq('s.crawlable', ':crawlable'))
->setParameter('crawlable', true) ->setParameter('crawlable', true)
->setMaxResults($blockSize); ->setMaxResults($blockSize)
->orderBy('s.shortCode');
$page = 0; $page = 0;
do { do {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl; namespace Shlinkio\Shlink\Core\ShortUrl;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@@ -13,6 +14,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
@@ -31,12 +33,12 @@ class UrlShortener implements UrlShortenerInterface
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function shorten(ShortUrlCreation $creation): ShortUrl public function shorten(ShortUrlCreation $creation): UrlShorteningResult
{ {
// First, check if a short URL exists for all provided params // First, check if a short URL exists for all provided params
$existingShortUrl = $this->findExistingShortUrlIfExists($creation); $existingShortUrl = $this->findExistingShortUrlIfExists($creation);
if ($existingShortUrl !== null) { if ($existingShortUrl !== null) {
return $existingShortUrl; return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl);
} }
$creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation); $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation);
@@ -51,9 +53,17 @@ class UrlShortener implements UrlShortenerInterface
return $shortUrl; return $shortUrl;
}); });
$this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId())); try {
$this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId()));
} catch (ContainerExceptionInterface $e) {
// Ignore container errors when dispatching the event.
// When using openswoole, this event will try to enqueue a task, which cannot be done outside an HTTP
// request.
// If the short URL is created from CLI, the event dispatching will fail.
return UrlShorteningResult::withErrorOnEventDispatching($newShortUrl, $e);
}
return $newShortUrl; return UrlShorteningResult::withoutErrorOnEventDispatching($newShortUrl);
} }
private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl private function findExistingShortUrlIfExists(ShortUrlCreation $creation): ?ShortUrl

View File

@@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
interface UrlShortenerInterface interface UrlShortenerInterface
{ {
@@ -15,5 +15,5 @@ interface UrlShortenerInterface
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
*/ */
public function shorten(ShortUrlCreation $creation): ShortUrl; public function shorten(ShortUrlCreation $creation): UrlShorteningResult;
} }

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model; namespace Shlinkio\Shlink\Core\Tag\Model;
use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
enum OrderableField: string enum OrderableField: string
{ {
case TAG = 'tag'; case TAG = 'tag';
@@ -15,20 +13,12 @@ enum OrderableField: string
/** @deprecated Use VISITS instead */ /** @deprecated Use VISITS instead */
case VISITS_COUNT = 'visitsCount'; case VISITS_COUNT = 'visitsCount';
public static function isAggregateField(string $field): bool public static function toSnakeCaseValidField(?string $field): self
{ {
$parsed = self::tryFrom($field); $parsed = $field !== null ? self::tryFrom($field) : self::TAG;
return $parsed !== null && $parsed !== self::TAG; return match ($parsed) {
}
public static function toSnakeCaseValidField(?string $field): string
{
$parsed = $field !== null ? self::tryFrom($field) : self::VISITS;
$normalized = match ($parsed) {
self::VISITS_COUNT, null => self::VISITS, self::VISITS_COUNT, null => self::VISITS,
default => $parsed, default => $parsed,
}; };
return camelCaseToSnakeCase($normalized->value);
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Repository; namespace Shlinkio\Shlink\Core\Tag\Repository;
use Doctrine\DBAL\Query\QueryBuilder as NativeQueryBuilder;
use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Spec;
@@ -14,10 +15,11 @@ use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName; use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function Functional\each;
use function Functional\map; use function Functional\map;
use function Shlinkio\Shlink\Core\camelCaseToSnakeCase;
use const PHP_INT_MAX; use const PHP_INT_MAX;
@@ -41,78 +43,90 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
*/ */
public function findTagsWithInfo(?TagsListFiltering $filtering = null): array public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{ {
$orderField = $filtering?->orderBy?->field; $orderField = OrderableField::toSnakeCaseValidField($filtering?->orderBy?->field);
$orderDir = $filtering?->orderBy?->direction; $orderDir = $filtering?->orderBy?->direction ?? 'ASC';
$orderMainQuery = $orderField !== null && OrderableField::isAggregateField($orderField); $apiKey = $filtering?->apiKey;
$conn = $this->getEntityManager()->getConnection(); $conn = $this->getEntityManager()->getConnection();
$subQb = $this->createQueryBuilder('t');
$subQb->select('t.id', 't.name');
if (! $orderMainQuery) { $applyApiKeyToNativeQb = static fn (NativeQueryBuilder $qb) =>
$subQb->orderBy('t.name', $orderDir ?? 'ASC') $apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) {
->setMaxResults($filtering?->limit ?? PHP_INT_MAX) Role::DOMAIN_SPECIFIC => $qb->andWhere(
->setFirstResult($filtering?->offset ?? 0); $qb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))),
} ),
Role::AUTHORED_SHORT_URLS => $qb->andWhere(
$qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())),
),
});
// For admins and when no API key is present, we'll return tags which are not linked to any short URL
$joiningMethod = ApiKey::isAdmin($apiKey) ? 'leftJoin' : 'join';
$tagsSubQb = $conn->createQueryBuilder();
$tagsSubQb
->select('t.id AS tag_id', 't.name AS tag', 'COUNT(DISTINCT s.id) AS short_urls_count')
->from('tags', 't')
->groupBy('t.id', 't.name')
->{$joiningMethod}('t', 'short_urls_in_tags', 'st', $tagsSubQb->expr()->eq('st.tag_id', 't.id'))
->{$joiningMethod}('st', 'short_urls', 's', $tagsSubQb->expr()->eq('st.short_url_id', 's.id'));
$searchTerm = $filtering?->searchTerm; $searchTerm = $filtering?->searchTerm;
if ($searchTerm !== null) { if ($searchTerm !== null) {
$subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); $tagsSubQb->andWhere($tagsSubQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%')));
} }
$apiKey = $filtering?->apiKey; $buildVisitsSubQb = static function (bool $excludeBots, string $aggregateAlias) use ($conn) {
$this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); $visitsSubQb = $conn->createQueryBuilder();
$commonJoinCondition = $visitsSubQb->expr()->eq('v.short_url_id', 's.id');
$visitsJoin = ! $excludeBots
? $commonJoinCondition
: $visitsSubQb->expr()->and(
$commonJoinCondition,
$visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')),
);
return $visitsSubQb
->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias)
->from('visits', 'v')
->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line
->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id'))
->groupBy('st.tag_id');
};
$allVisitsSubQb = $buildVisitsSubQb(false, 'visits');
$nonBotVisitsSubQb = $buildVisitsSubQb(true, 'non_bot_visits');
// Apply API key specification to all sub-queries
each([$tagsSubQb, $allVisitsSubQb, $nonBotVisitsSubQb], $applyApiKeyToNativeQb);
// A native query builder needs to be used here, because DQL and ORM query builders do not support // A native query builder needs to be used here, because DQL and ORM query builders do not support
// sub-queries at "from" and "join" level. // sub-queries at "from" and "join" level.
// If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient. // If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient.
$nativeQb = $conn->createQueryBuilder(); $mainQb = $conn->createQueryBuilder();
$nativeQb $mainQb
->select( ->select(
't.id_0 AS id', 't.tag AS tag',
't.name_1 AS name', 'COALESCE(v.visits, 0) AS visits', // COALESCE required for postgres to properly order
'COUNT(DISTINCT s.id) AS short_urls_count', 'COALESCE(b.non_bot_visits, 0) AS non_bot_visits',
'COUNT(DISTINCT v.id) AS visits', // Native queries require snake_case for cross-db compatibility 'COALESCE(t.short_urls_count, 0) AS short_urls_count',
'COUNT(DISTINCT v2.id) AS non_bot_visits',
) )
->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->from('(' . $tagsSubQb->getSQL() . ')', 't')
->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) ->leftJoin('t', '(' . $allVisitsSubQb->getSQL() . ')', 'v', $mainQb->expr()->eq('t.tag_id', 'v.tag_id'))
->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) ->leftJoin('t', '(' . $nonBotVisitsSubQb->getSQL() . ')', 'b', $mainQb->expr()->eq('t.tag_id', 'b.tag_id'))
->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('st.short_url_id', 'v.short_url_id')) ->setMaxResults($filtering?->limit ?? PHP_INT_MAX)
->leftJoin('st', 'visits', 'v2', $nativeQb->expr()->and( // @phpstan-ignore-line ->setFirstResult($filtering?->offset ?? 0);
$nativeQb->expr()->eq('st.short_url_id', 'v2.short_url_id'),
$nativeQb->expr()->eq('v2.potential_bot', $conn->quote('0')),
))
->groupBy('t.id_0', 't.name_1');
// Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates $mainQb->orderBy(camelCaseToSnakeCase($orderField->value), $orderDir);
$apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) { if ($orderField !== OrderableField::TAG) {
Role::DOMAIN_SPECIFIC => $nativeQb->andWhere( // Add ordering by tag name, as a fallback in case of same amounts
$nativeQb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), $mainQb->addOrderBy('tag', 'ASC');
),
Role::AUTHORED_SHORT_URLS => $nativeQb->andWhere(
$nativeQb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())),
),
});
if ($orderMainQuery) {
$nativeQb
->orderBy(OrderableField::toSnakeCaseValidField($orderField), $orderDir ?? 'ASC')
->setMaxResults($filtering?->limit ?? PHP_INT_MAX)
->setFirstResult($filtering?->offset ?? 0);
} }
// Add ordering by tag name, as a fallback in case of same amount, or as default ordering
$nativeQb->addOrderBy('t.name_1', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir);
$rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm = new ResultSetMappingBuilder($this->getEntityManager());
$rsm->addScalarResult('name', 'tag'); $rsm->addScalarResult('tag', 'tag');
$rsm->addScalarResult('short_urls_count', 'shortUrlsCount');
$rsm->addScalarResult('visits', 'visits'); $rsm->addScalarResult('visits', 'visits');
$rsm->addScalarResult('non_bot_visits', 'nonBotVisits'); $rsm->addScalarResult('non_bot_visits', 'nonBotVisits');
$rsm->addScalarResult('short_urls_count', 'shortUrlsCount');
return map( return map(
$this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), $this->getEntityManager()->createNativeQuery($mainQb->getSQL(), $rsm)->getResult(),
TagInfo::fromRawData(...), TagInfo::fromRawData(...),
); );
} }

View File

@@ -59,7 +59,7 @@ class TagService implements TagServiceInterface
*/ */
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void
{ {
if ($apiKey !== null && ! $apiKey->isAdmin()) { if (! ApiKey::isAdmin($apiKey)) {
throw ForbiddenTagOperationException::forDeletion(); throw ForbiddenTagOperationException::forDeletion();
} }
@@ -75,7 +75,7 @@ class TagService implements TagServiceInterface
*/ */
public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag
{ {
if ($apiKey !== null && ! $apiKey->isAdmin()) { if (! ApiKey::isAdmin($apiKey)) {
throw ForbiddenTagOperationException::forRenaming(); throw ForbiddenTagOperationException::forRenaming();
} }

View File

@@ -17,12 +17,14 @@ class DoctrineBatchHelper implements DoctrineBatchHelperInterface
} }
/** /**
* @template T
* @param iterable<T> $resultSet
* @return iterable<T>
* @throws Throwable * @throws Throwable
*/ */
public function wrapIterable(iterable $resultSet, int $batchSize): iterable public function wrapIterable(iterable $resultSet, int $batchSize): iterable
{ {
$iteration = 0; $iteration = 0;
$this->em->beginTransaction(); $this->em->beginTransaction();
try { try {
@@ -33,7 +35,6 @@ class DoctrineBatchHelper implements DoctrineBatchHelperInterface
} }
} catch (Throwable $e) { } catch (Throwable $e) {
$this->em->rollback(); $this->em->rollback();
throw $e; throw $e;
} }

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\Action; namespace ShlinkioApiTest\Shlink\Core\Action;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
@@ -12,17 +14,14 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT;
class RedirectTest extends ApiTestCase class RedirectTest extends ApiTestCase
{ {
/** #[Test, DataProvider('provideUserAgents')]
* @test
* @dataProvider provideUserAgents
*/
public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void
{ {
$response = $this->callShortUrl('def456', $userAgent); $response = $this->callShortUrl('def456', $userAgent);
self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); self::assertEquals($expectedRedirect, $response->getHeaderLine('Location'));
} }
public function provideUserAgents(): iterable public static function provideUserAgents(): iterable
{ {
yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android']; yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android'];
yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios']; yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios'];

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\Action;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class RobotsTest extends ApiTestCase
{
#[Test]
public function expectedListOfCrawlableShortCodesIsReturned(): void
{
$resp = $this->callShortUrl('robots.txt');
$body = $resp->getBody()->__toString();
self::assertEquals(200, $resp->getStatusCode());
self::assertEquals(
<<<ROBOTS
# For more information about the robots.txt standard, see:
# https://www.robotstxt.org/orig.html
User-agent: *
Allow: /abc123
Allow: /custom
Disallow: /
ROBOTS,
$body,
);
}
}

View File

@@ -6,6 +6,7 @@ namespace ShlinkioDbTest\Shlink\Core\Domain\Repository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
@@ -26,7 +27,7 @@ class DomainRepositoryTest extends DatabaseTestCase
$this->repo = $this->getEntityManager()->getRepository(Domain::class); $this->repo = $this->getEntityManager()->getRepository(Domain::class);
} }
/** @test */ #[Test]
public function expectedDomainsAreFoundWhenNoApiKeyIsInvolved(): void public function expectedDomainsAreFoundWhenNoApiKeyIsInvolved(): void
{ {
$fooDomain = Domain::withAuthority('foo.com'); $fooDomain = Domain::withAuthority('foo.com');
@@ -61,7 +62,7 @@ class DomainRepositoryTest extends DatabaseTestCase
self::assertTrue($this->repo->domainExists('detached.com')); self::assertTrue($this->repo->domainExists('detached.com'));
} }
/** @test */ #[Test]
public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void public function expectedDomainsAreFoundWhenApiKeyIsProvided(): void
{ {
$authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $authorApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
@@ -131,7 +132,7 @@ class DomainRepositoryTest extends DatabaseTestCase
{ {
return ShortUrl::create( return ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'foo'], ['domain' => $domain->authority, 'apiKey' => $apiKey, 'longUrl' => 'https://foo'],
), ),
new class ($domain) implements ShortUrlRelationResolverInterface { new class ($domain) implements ShortUrlRelationResolverInterface {
public function __construct(private Domain $domain) public function __construct(private Domain $domain)

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQuery; use Shlinkio\Shlink\Core\ShortUrl\Repository\CrawlableShortCodesQuery;
@@ -19,11 +20,11 @@ class CrawlableShortCodesQueryTest extends DatabaseTestCase
$this->query = new CrawlableShortCodesQuery($em, $em->getClassMetadata(ShortUrl::class)); $this->query = new CrawlableShortCodesQuery($em, $em->getClassMetadata(ShortUrl::class));
} }
/** @test */ #[Test]
public function invokingQueryReturnsExpectedResult(): void public function invokingQueryReturnsExpectedResult(): void
{ {
$createShortUrl = fn (bool $crawlable) => ShortUrl::create( $createShortUrl = fn (bool $crawlable) => ShortUrl::create(
ShortUrlCreation::fromRawData(['crawlable' => $crawlable, 'longUrl' => 'foo.com']), ShortUrlCreation::fromRawData(['crawlable' => $crawlable, 'longUrl' => 'https://foo.com']),
); );
$shortUrl1 = $createShortUrl(true); $shortUrl1 = $createShortUrl(true);

View File

@@ -6,6 +6,7 @@ namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\Attributes\Test;
use ReflectionObject; use ReflectionObject;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\Ordering;
@@ -37,28 +38,28 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
$this->relationResolver = new PersistenceShortUrlRelationResolver($em); $this->relationResolver = new PersistenceShortUrlRelationResolver($em);
} }
/** @test */ #[Test]
public function countListReturnsProperNumberOfResults(): void public function countListReturnsProperNumberOfResults(): void
{ {
$count = 5; $count = 5;
for ($i = 0; $i < $count; $i++) { for ($i = 0; $i < $count; $i++) {
$this->getEntityManager()->persist(ShortUrl::withLongUrl((string) $i)); $this->getEntityManager()->persist(ShortUrl::withLongUrl('https://' . $i));
} }
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering()));
} }
/** @test */ #[Test]
public function findListProperlyFiltersResult(): void public function findListProperlyFiltersResult(): void
{ {
$foo = ShortUrl::create( $foo = ShortUrl::create(
ShortUrlCreation::fromRawData(['longUrl' => 'foo', 'tags' => ['bar']]), ShortUrlCreation::fromRawData(['longUrl' => 'https://foo', 'tags' => ['bar']]),
$this->relationResolver, $this->relationResolver,
); );
$this->getEntityManager()->persist($foo); $this->getEntityManager()->persist($foo);
$bar = ShortUrl::withLongUrl('bar'); $bar = ShortUrl::withLongUrl('https://bar');
$visits = map(range(0, 5), function () use ($bar) { $visits = map(range(0, 5), function () use ($bar) {
$visit = Visit::forValidShortUrl($bar, Visitor::botInstance()); $visit = Visit::forValidShortUrl($bar, Visitor::botInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
@@ -68,7 +69,7 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
$bar->setVisits(new ArrayCollection($visits)); $bar->setVisits(new ArrayCollection($visits));
$this->getEntityManager()->persist($bar); $this->getEntityManager()->persist($bar);
$foo2 = ShortUrl::withLongUrl('foo_2'); $foo2 = ShortUrl::withLongUrl('https://foo_2');
$visits2 = map(range(0, 3), function () use ($foo2) { $visits2 = map(range(0, 3), function () use ($foo2) {
$visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance()); $visit = Visit::forValidShortUrl($foo2, Visitor::emptyInstance());
$this->getEntityManager()->persist($visit); $this->getEntityManager()->persist($visit);
@@ -143,10 +144,10 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void public function findListProperlyMapsFieldNamesToColumnNamesWhenOrdering(): void
{ {
$urls = ['a', 'z', 'c', 'b']; $urls = ['https://a', 'https://z', 'https://c', 'https://b'];
foreach ($urls as $url) { foreach ($urls as $url) {
$this->getEntityManager()->persist(ShortUrl::withLongUrl($url)); $this->getEntityManager()->persist(ShortUrl::withLongUrl($url));
} }
@@ -158,37 +159,37 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
); );
self::assertCount(count($urls), $result); self::assertCount(count($urls), $result);
self::assertEquals('a', $result[0]->getLongUrl()); self::assertEquals('https://a', $result[0]->getLongUrl());
self::assertEquals('b', $result[1]->getLongUrl()); self::assertEquals('https://b', $result[1]->getLongUrl());
self::assertEquals('c', $result[2]->getLongUrl()); self::assertEquals('https://c', $result[2]->getLongUrl());
self::assertEquals('z', $result[3]->getLongUrl()); self::assertEquals('https://z', $result[3]->getLongUrl());
} }
/** @test */ #[Test]
public function findListReturnsOnlyThoseWithMatchingTags(): void public function findListReturnsOnlyThoseWithMatchingTags(): void
{ {
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo1', 'longUrl' => 'https://foo1',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1); $this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo2', 'longUrl' => 'https://foo2',
'tags' => ['foo', 'baz'], 'tags' => ['foo', 'baz'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo3', 'longUrl' => 'https://foo3',
'tags' => ['foo'], 'tags' => ['foo'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo4', 'longUrl' => 'https://foo4',
'tags' => ['bar', 'baz'], 'tags' => ['bar', 'baz'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl4); $this->getEntityManager()->persist($shortUrl4);
$shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo5', 'longUrl' => 'https://foo5',
'tags' => ['bar', 'baz'], 'tags' => ['bar', 'baz'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl5); $this->getEntityManager()->persist($shortUrl5);
@@ -273,21 +274,21 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findListReturnsOnlyThoseWithMatchingDomains(): void public function findListReturnsOnlyThoseWithMatchingDomains(): void
{ {
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo1', 'longUrl' => 'https://foo1',
'domain' => null, 'domain' => null,
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1); $this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo2', 'longUrl' => 'https://foo2',
'domain' => null, 'domain' => null,
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo3', 'longUrl' => 'https://foo3',
'domain' => 'another.com', 'domain' => 'another.com',
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
@@ -309,26 +310,26 @@ class ShortUrlListRepositoryTest extends DatabaseTestCase
self::assertCount(0, $this->repo->findList($buildFiltering('no results'))); self::assertCount(0, $this->repo->findList($buildFiltering('no results')));
} }
/** @test */ #[Test]
public function findListReturnsOnlyThoseWithoutExcludedUrls(): void public function findListReturnsOnlyThoseWithoutExcludedUrls(): void
{ {
$shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl1 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo1', 'longUrl' => 'https://foo1',
'validUntil' => Chronos::now()->addDays(1)->toAtomString(), 'validUntil' => Chronos::now()->addDays(1)->toAtomString(),
'maxVisits' => 100, 'maxVisits' => 100,
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl1); $this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo2', 'longUrl' => 'https://foo2',
'validUntil' => Chronos::now()->subDays(1)->toAtomString(), 'validUntil' => Chronos::now()->subDays(1)->toAtomString(),
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo3', 'longUrl' => 'https://foo3',
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl4 = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo4', 'longUrl' => 'https://foo4',
'maxVisits' => 3, 'maxVisits' => 3,
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl4); $this->getEntityManager()->persist($shortUrl4);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository; namespace ShlinkioDbTest\Shlink\Core\ShortUrl\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\DBAL\Platforms\SQLServerPlatform; use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
@@ -31,19 +31,21 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
} }
/** @test */ #[Test]
public function findOneWithDomainFallbackReturnsProperData(): void public function findOneWithDomainFallbackReturnsProperData(): void
{ {
$regularOne = ShortUrl::create(ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'foo'])); $regularOne = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'Foo', 'longUrl' => 'https://foo']),
);
$this->getEntityManager()->persist($regularOne); $this->getEntityManager()->persist($regularOne);
$withDomain = ShortUrl::create(ShortUrlCreation::fromRawData( $withDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 'example.com', 'customSlug' => 'domain-short-code', 'longUrl' => 'foo'], ['domain' => 'example.com', 'customSlug' => 'domain-short-code', 'longUrl' => 'https://foo'],
)); ));
$this->getEntityManager()->persist($withDomain); $this->getEntityManager()->persist($withDomain);
$withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData( $withDomainDuplicatingRegular = ShortUrl::create(ShortUrlCreation::fromRawData(
['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'foo_with_domain'], ['domain' => 's.test', 'customSlug' => 'Foo', 'longUrl' => 'https://foo_with_domain'],
)); ));
$this->getEntityManager()->persist($withDomainDuplicatingRegular); $this->getEntityManager()->persist($withDomainDuplicatingRegular);
@@ -55,19 +57,16 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
)); ));
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
ShortUrlIdentifier::fromShortCodeAndDomain('foo'), ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
ShortUrlMode::LOOSELY, ShortUrlMode::LOOSE,
)); ));
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
ShortUrlIdentifier::fromShortCodeAndDomain('fOo'), ShortUrlIdentifier::fromShortCodeAndDomain('fOo'),
ShortUrlMode::LOOSELY, ShortUrlMode::LOOSE,
));
self::assertNull($this->repo->findOneWithDomainFallback(
ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
ShortUrlMode::STRICT,
)); ));
// TODO MS is doing loosely checks always, making this fail.
if (! $this->getEntityManager()->getConnection()->getDatabasePlatform() instanceof SQLServerPlatform) {
self::assertNull($this->repo->findOneWithDomainFallback(
ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
ShortUrlMode::STRICT,
));
}
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback( self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()), ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()),
ShortUrlMode::STRICT, ShortUrlMode::STRICT,
@@ -101,17 +100,17 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void
{ {
$shortUrlWithoutDomain = ShortUrl::create( $shortUrlWithoutDomain = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'https://foo']),
); );
$this->getEntityManager()->persist($shortUrlWithoutDomain); $this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = ShortUrl::create( $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'https://foo'],
); ));
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@@ -130,17 +129,17 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findOneLooksForShortUrlInProperSetOfTables(): void public function findOneLooksForShortUrlInProperSetOfTables(): void
{ {
$shortUrlWithoutDomain = ShortUrl::create( $shortUrlWithoutDomain = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'foo']), ShortUrlCreation::fromRawData(['customSlug' => 'my-cool-slug', 'longUrl' => 'https://foo']),
); );
$this->getEntityManager()->persist($shortUrlWithoutDomain); $this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = ShortUrl::create( $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'foo']), ['domain' => 's.test', 'customSlug' => 'another-slug', 'longUrl' => 'https://foo'],
); ));
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
@@ -157,70 +156,75 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findOneMatchingReturnsNullForNonExistingShortUrls(): void public function findOneMatchingReturnsNullForNonExistingShortUrls(): void
{ {
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'foobar']))); self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData(['longUrl' => 'https://foobar'])));
self::assertNull($this->repo->findOneMatching( self::assertNull($this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['longUrl' => 'foobar', 'tags' => ['foo', 'bar']]), ShortUrlCreation::fromRawData(['longUrl' => 'https://foobar', 'tags' => ['foo', 'bar']]),
)); ));
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([ self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => Chronos::parse('2020-03-05 20:18:30'), 'validSince' => Chronos::parse('2020-03-05 20:18:30'),
'customSlug' => 'this_slug_does_not_exist', 'customSlug' => 'this_slug_does_not_exist',
'longUrl' => 'foobar', 'longUrl' => 'https://foobar',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]))); ])));
} }
/** @test */ #[Test]
public function findOneMatchingAppliesProperConditions(): void public function findOneMatchingAppliesProperConditions(): void
{ {
$start = Chronos::parse('2020-03-05 20:18:30'); $start = Chronos::parse('2020-03-05 20:18:30');
$end = Chronos::parse('2021-03-05 20:18:30'); $end = Chronos::parse('2021-03-05 20:18:30');
$shortUrl = ShortUrl::create( $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), ['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
$this->relationResolver, ), $this->relationResolver);
);
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])); $shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'https://bar']),
);
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = ShortUrl::create( $shortUrl3 = ShortUrl::create(
ShortUrlCreation::fromRawData(['validSince' => $start, 'validUntil' => $end, 'longUrl' => 'baz']), ShortUrlCreation::fromRawData(['validSince' => $start, 'validUntil' => $end, 'longUrl' => 'https://baz']),
); );
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = ShortUrl::create( $shortUrl4 = ShortUrl::create(
ShortUrlCreation::fromRawData(['customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'foo']), ShortUrlCreation::fromRawData(['customSlug' => 'custom', 'validUntil' => $end, 'longUrl' => 'https://foo']),
); );
$this->getEntityManager()->persist($shortUrl4); $this->getEntityManager()->persist($shortUrl4);
$shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])); $shortUrl5 = ShortUrl::create(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://foo']));
$this->getEntityManager()->persist($shortUrl5); $this->getEntityManager()->persist($shortUrl5);
$shortUrl6 = ShortUrl::create(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])); $shortUrl6 = ShortUrl::create(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'https://foo']),
);
$this->getEntityManager()->persist($shortUrl6); $this->getEntityManager()->persist($shortUrl6);
$this->getEntityManager()->flush(); $this->getEntityManager()->flush();
self::assertSame( self::assertSame(
$shortUrl, $shortUrl,
$this->repo->findOneMatching( $this->repo->findOneMatching(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), ['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
), )),
); );
self::assertSame( self::assertSame(
$shortUrl2, $shortUrl2,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'bar'])), $this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['validUntil' => $end, 'longUrl' => 'https://bar']),
),
); );
self::assertSame( self::assertSame(
$shortUrl3, $shortUrl3,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'validUntil' => $end, 'validUntil' => $end,
'longUrl' => 'baz', 'longUrl' => 'https://baz',
])), ])),
); );
self::assertSame( self::assertSame(
@@ -228,26 +232,28 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'customSlug' => 'custom', 'customSlug' => 'custom',
'validUntil' => $end, 'validUntil' => $end,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
])), ])),
); );
self::assertSame( self::assertSame(
$shortUrl5, $shortUrl5,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'foo'])), $this->repo->findOneMatching(ShortUrlCreation::fromRawData(['maxVisits' => 3, 'longUrl' => 'https://foo'])),
); );
self::assertSame( self::assertSame(
$shortUrl6, $shortUrl6,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'foo'])), $this->repo->findOneMatching(
ShortUrlCreation::fromRawData(['domain' => 's.test', 'longUrl' => 'https://foo']),
),
); );
} }
/** @test */ #[Test]
public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void
{ {
$start = Chronos::parse('2020-03-05 20:18:30'); $start = Chronos::parse('2020-03-05 20:18:30');
$tags = ['foo', 'bar']; $tags = ['foo', 'bar'];
$meta = ShortUrlCreation::fromRawData( $meta = ShortUrlCreation::fromRawData(
['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'foo', 'tags' => $tags], ['validSince' => $start, 'maxVisits' => 50, 'longUrl' => 'https://foo', 'tags' => $tags],
); );
$shortUrl1 = ShortUrl::create($meta, $this->relationResolver); $shortUrl1 = ShortUrl::create($meta, $this->relationResolver);
@@ -269,7 +275,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertNotSame($shortUrl3, $result); self::assertNotSame($shortUrl3, $result);
} }
/** @test */ #[Test]
public function findOneMatchingAppliesProvidedApiKeyConditions(): void public function findOneMatchingAppliesProvidedApiKeyConditions(): void
{ {
$start = Chronos::parse('2020-03-05 20:18:30'); $start = Chronos::parse('2020-03-05 20:18:30');
@@ -296,14 +302,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
'validSince' => $start, 'validSince' => $start,
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'domain' => $rightDomain->authority, 'domain' => $rightDomain->authority,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$nonDomainShortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $nonDomainShortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'longUrl' => 'non-domain', 'longUrl' => 'https://non-domain',
]), $this->relationResolver); ]), $this->relationResolver);
$this->getEntityManager()->persist($nonDomainShortUrl); $this->getEntityManager()->persist($nonDomainShortUrl);
@@ -311,26 +317,26 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertSame( self::assertSame(
$shortUrl, $shortUrl,
$this->repo->findOneMatching( $this->repo->findOneMatching(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['validSince' => $start, 'longUrl' => 'foo', 'tags' => ['foo', 'bar']]), ['validSince' => $start, 'longUrl' => 'https://foo', 'tags' => ['foo', 'bar']],
), )),
); );
self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]))); ])));
self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([ self::assertSame($shortUrl, $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'apiKey' => $adminApiKey, 'apiKey' => $adminApiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]))); ])));
self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([ self::assertNull($this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'apiKey' => $otherApiKey, 'apiKey' => $otherApiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
]))); ])));
@@ -339,7 +345,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->authority, 'domain' => $rightDomain->authority,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
])), ])),
); );
@@ -349,7 +355,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->authority, 'domain' => $rightDomain->authority,
'apiKey' => $rightDomainApiKey, 'apiKey' => $rightDomainApiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
])), ])),
); );
@@ -359,7 +365,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->authority, 'domain' => $rightDomain->authority,
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
])), ])),
); );
@@ -368,7 +374,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
'validSince' => $start, 'validSince' => $start,
'domain' => $rightDomain->authority, 'domain' => $rightDomain->authority,
'apiKey' => $wrongDomainApiKey, 'apiKey' => $wrongDomainApiKey,
'longUrl' => 'foo', 'longUrl' => 'https://foo',
'tags' => ['foo', 'bar'], 'tags' => ['foo', 'bar'],
])), ])),
); );
@@ -377,29 +383,29 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$nonDomainShortUrl, $nonDomainShortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $apiKey, 'apiKey' => $apiKey,
'longUrl' => 'non-domain', 'longUrl' => 'https://non-domain',
])), ])),
); );
self::assertSame( self::assertSame(
$nonDomainShortUrl, $nonDomainShortUrl,
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $adminApiKey, 'apiKey' => $adminApiKey,
'longUrl' => 'non-domain', 'longUrl' => 'https://non-domain',
])), ])),
); );
self::assertNull( self::assertNull(
$this->repo->findOneMatching(ShortUrlCreation::fromRawData([ $this->repo->findOneMatching(ShortUrlCreation::fromRawData([
'apiKey' => $otherApiKey, 'apiKey' => $otherApiKey,
'longUrl' => 'non-domain', 'longUrl' => 'https://non-domain',
])), ])),
); );
} }
/** @test */ #[Test]
public function importedShortUrlsAreFoundWhenExpected(): void public function importedShortUrlsAreFoundWhenExpected(): void
{ {
$buildImported = static fn (string $shortCode, ?String $domain = null) => $buildImported = static fn (string $shortCode, ?string $domain = null) =>
new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), $domain, $shortCode, null); new ImportedShlinkUrl(ImportSource::BITLY, 'https://foo', [], Chronos::now(), $domain, $shortCode, null);
$shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true);
$this->getEntityManager()->persist($shortUrlWithoutDomain); $this->getEntityManager()->persist($shortUrlWithoutDomain);

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Tag\Paginator\Adapter; namespace ShlinkioDbTest\Shlink\Core\Tag\Paginator\Adapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
@@ -24,9 +26,8 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase
/** /**
* @param int<0, max> $offset * @param int<0, max> $offset
* @param int<0, max> $length * @param int<0, max> $length
* @test
* @dataProvider provideFilters
*/ */
#[Test, DataProvider('provideFilters')]
public function expectedListOfTagsIsReturned( public function expectedListOfTagsIsReturned(
?string $searchTerm, ?string $searchTerm,
?string $orderBy, ?string $orderBy,
@@ -52,7 +53,7 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase
self::assertEquals($expectedTotalCount, $adapter->getNbResults()); self::assertEquals($expectedTotalCount, $adapter->getNbResults());
} }
public function provideFilters(): iterable public static function provideFilters(): iterable
{ {
yield [null, null, 0, 10, ['another', 'bar', 'baz', 'foo'], 4]; yield [null, null, 0, 10, ['another', 'bar', 'baz', 'foo'], 4];
yield [null, null, 2, 10, ['baz', 'foo'], 4]; yield [null, null, 2, 10, ['baz', 'foo'], 4];

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Tag\Repository; namespace ShlinkioDbTest\Shlink\Core\Tag\Repository;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
@@ -34,13 +36,13 @@ class TagRepositoryTest extends DatabaseTestCase
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
} }
/** @test */ #[Test]
public function deleteByNameDoesNothingWhenEmptyListIsProvided(): void public function deleteByNameDoesNothingWhenEmptyListIsProvided(): void
{ {
self::assertEquals(0, $this->repo->deleteByName([])); self::assertEquals(0, $this->repo->deleteByName([]));
} }
/** @test */ #[Test]
public function allTagsWhichMatchNameAreDeleted(): void public function allTagsWhichMatchNameAreDeleted(): void
{ {
$names = ['foo', 'bar', 'baz']; $names = ['foo', 'bar', 'baz'];
@@ -54,10 +56,7 @@ class TagRepositoryTest extends DatabaseTestCase
self::assertEquals(2, $this->repo->deleteByName($toDelete)); self::assertEquals(2, $this->repo->deleteByName($toDelete));
} }
/** #[Test, DataProvider('provideFilters')]
* @test
* @dataProvider provideFilters
*/
public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void
{ {
$names = ['foo', 'bar', 'baz', 'another']; $names = ['foo', 'bar', 'baz', 'another'];
@@ -75,7 +74,7 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags] = array_chunk($names, 3); [$firstUrlTags] = array_chunk($names, 3);
$secondUrlTags = [$names[0]]; $secondUrlTags = [$names[0]];
$metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData( $metaWithTags = static fn (array $tags, ?ApiKey $apiKey) => ShortUrlCreation::fromRawData(
['longUrl' => 'longUrl', 'tags' => $tags, 'apiKey' => $apiKey], ['longUrl' => 'https://longUrl', 'tags' => $tags, 'apiKey' => $apiKey],
); );
$shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver); $shortUrl = ShortUrl::create($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver);
@@ -109,7 +108,7 @@ class TagRepositoryTest extends DatabaseTestCase
} }
} }
public function provideFilters(): iterable public static function provideFilters(): iterable
{ {
$defaultList = [ $defaultList = [
['another', 0, 0, 0], ['another', 0, 0, 0],
@@ -221,7 +220,7 @@ class TagRepositoryTest extends DatabaseTestCase
]]; ]];
} }
/** @test */ #[Test]
public function tagExistsReturnsExpectedResultBasedOnApiKey(): void public function tagExistsReturnsExpectedResultBasedOnApiKey(): void
{ {
$domain = Domain::withAuthority('foo.com'); $domain = Domain::withAuthority('foo.com');
@@ -241,15 +240,14 @@ class TagRepositoryTest extends DatabaseTestCase
[$firstUrlTags, $secondUrlTags] = array_chunk($names, 3); [$firstUrlTags, $secondUrlTags] = array_chunk($names, 3);
$shortUrl = ShortUrl::create( $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(
ShortUrlCreation::fromRawData(['apiKey' => $authorApiKey, 'longUrl' => 'longUrl', 'tags' => $firstUrlTags]), ['apiKey' => $authorApiKey, 'longUrl' => 'https://longUrl', 'tags' => $firstUrlTags],
$this->relationResolver, ), $this->relationResolver);
);
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$shortUrl2 = ShortUrl::create( $shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['domain' => $domain->authority, 'longUrl' => 'longUrl', 'tags' => $secondUrlTags], ['domain' => $domain->authority, 'longUrl' => 'https://longUrl', 'tags' => $secondUrlTags],
), ),
$this->relationResolver, $this->relationResolver,
); );

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Visit\Repository; namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
@@ -25,10 +27,7 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
$this->repo = new VisitLocationRepository($em, $em->getClassMetadata(Visit::class)); $this->repo = new VisitLocationRepository($em, $em->getClassMetadata(Visit::class));
} }
/** #[Test, DataProvider('provideBlockSize')]
* @test
* @dataProvider provideBlockSize
*/
public function findVisitsReturnsProperVisits(int $blockSize): void public function findVisitsReturnsProperVisits(int $blockSize): void
{ {
$shortUrl = ShortUrl::createFake(); $shortUrl = ShortUrl::createFake();
@@ -56,7 +55,7 @@ class VisitLocationRepositoryTest extends DatabaseTestCase
self::assertCount(6, [...$all]); self::assertCount(6, [...$all]);
} }
public function provideBlockSize(): iterable public static function provideBlockSize(): iterable
{ {
return map(range(1, 10), fn (int $value) => [$value]); return map(range(1, 10), fn (int $value) => [$value]);
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioDbTest\Shlink\Core\Visit\Repository; namespace ShlinkioDbTest\Shlink\Core\Visit\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use ReflectionObject; use ReflectionObject;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Entity\Domain;
@@ -40,7 +41,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager()); $this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
} }
/** @test */ #[Test]
public function findVisitsByShortCodeReturnsProperData(): void public function findVisitsByShortCodeReturnsProperData(): void
{ {
[$shortCode, $domain] = $this->createShortUrlsAndVisits(); [$shortCode, $domain] = $this->createShortUrlsAndVisits();
@@ -89,7 +90,7 @@ class VisitRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function countVisitsByShortCodeReturnsProperData(): void public function countVisitsByShortCodeReturnsProperData(): void
{ {
[$shortCode, $domain] = $this->createShortUrlsAndVisits(); [$shortCode, $domain] = $this->createShortUrlsAndVisits();
@@ -126,7 +127,7 @@ class VisitRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findVisitsByShortCodeReturnsProperDataWhenUsingAPiKeys(): void public function findVisitsByShortCodeReturnsProperDataWhenUsingAPiKeys(): void
{ {
$adminApiKey = ApiKey::create(); $adminApiKey = ApiKey::create();
@@ -158,7 +159,7 @@ class VisitRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findVisitsByTagReturnsProperData(): void public function findVisitsByTagReturnsProperData(): void
{ {
$foo = 'foo'; $foo = 'foo';
@@ -183,7 +184,7 @@ class VisitRepositoryTest extends DatabaseTestCase
))); )));
} }
/** @test */ #[Test]
public function countVisitsByTagReturnsProperData(): void public function countVisitsByTagReturnsProperData(): void
{ {
$foo = 'foo'; $foo = 'foo';
@@ -205,7 +206,7 @@ class VisitRepositoryTest extends DatabaseTestCase
))); )));
} }
/** @test */ #[Test]
public function findVisitsByDomainReturnsProperData(): void public function findVisitsByDomainReturnsProperData(): void
{ {
$this->createShortUrlsAndVisits('s.test'); $this->createShortUrlsAndVisits('s.test');
@@ -229,7 +230,7 @@ class VisitRepositoryTest extends DatabaseTestCase
))); )));
} }
/** @test */ #[Test]
public function countVisitsByDomainReturnsProperData(): void public function countVisitsByDomainReturnsProperData(): void
{ {
$this->createShortUrlsAndVisits('s.test'); $this->createShortUrlsAndVisits('s.test');
@@ -253,7 +254,7 @@ class VisitRepositoryTest extends DatabaseTestCase
))); )));
} }
/** @test */ #[Test]
public function countVisitsReturnsExpectedResultBasedOnApiKey(): void public function countVisitsReturnsExpectedResultBasedOnApiKey(): void
{ {
$domain = Domain::withAuthority('foo.com'); $domain = Domain::withAuthority('foo.com');
@@ -265,7 +266,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->persist($apiKey1); $this->getEntityManager()->persist($apiKey1);
$shortUrl = ShortUrl::create( $shortUrl = ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], ['apiKey' => $apiKey1, 'domain' => $domain->authority, 'longUrl' => 'https://longUrl'],
), ),
$this->relationResolver, $this->relationResolver,
); );
@@ -274,13 +275,15 @@ class VisitRepositoryTest extends DatabaseTestCase
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()));
$this->getEntityManager()->persist($apiKey2); $this->getEntityManager()->persist($apiKey2);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'longUrl'])); $shortUrl2 = ShortUrl::create(
ShortUrlCreation::fromRawData(['apiKey' => $apiKey2, 'longUrl' => 'https://longUrl']),
);
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 5); $this->createVisitsForShortUrl($shortUrl2, 5);
$shortUrl3 = ShortUrl::create( $shortUrl3 = ShortUrl::create(
ShortUrlCreation::fromRawData( ShortUrlCreation::fromRawData(
['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'longUrl'], ['apiKey' => $apiKey2, 'domain' => $domain->authority, 'longUrl' => 'https://longUrl'],
), ),
$this->relationResolver, $this->relationResolver,
); );
@@ -316,10 +319,10 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true))); self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true)));
} }
/** @test */ #[Test]
public function findOrphanVisitsReturnsExpectedResult(): void public function findOrphanVisitsReturnsExpectedResult(): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://longUrl']));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7); $this->createVisitsForShortUrl($shortUrl, 7);
@@ -365,10 +368,10 @@ class VisitRepositoryTest extends DatabaseTestCase
))); )));
} }
/** @test */ #[Test]
public function countOrphanVisitsReturnsExpectedResult(): void public function countOrphanVisitsReturnsExpectedResult(): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'longUrl'])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://longUrl']));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7); $this->createVisitsForShortUrl($shortUrl, 7);
@@ -402,18 +405,18 @@ class VisitRepositoryTest extends DatabaseTestCase
)); ));
} }
/** @test */ #[Test]
public function findNonOrphanVisitsReturnsExpectedResult(): void public function findNonOrphanVisitsReturnsExpectedResult(): void
{ {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '1'])); $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://1']));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->createVisitsForShortUrl($shortUrl, 7); $this->createVisitsForShortUrl($shortUrl, 7);
$shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '2'])); $shortUrl2 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://2']));
$this->getEntityManager()->persist($shortUrl2); $this->getEntityManager()->persist($shortUrl2);
$this->createVisitsForShortUrl($shortUrl2, 4); $this->createVisitsForShortUrl($shortUrl2, 4);
$shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => '3'])); $shortUrl3 = ShortUrl::create(ShortUrlCreation::fromRawData(['longUrl' => 'https://3']));
$this->getEntityManager()->persist($shortUrl3); $this->getEntityManager()->persist($shortUrl3);
$this->createVisitsForShortUrl($shortUrl3, 10); $this->createVisitsForShortUrl($shortUrl3, 10);
@@ -445,7 +448,7 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5))); self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5)));
} }
/** @test */ #[Test]
public function findMostRecentOrphanVisitReturnsExpectedVisit(): void public function findMostRecentOrphanVisitReturnsExpectedVisit(): void
{ {
$this->assertNull($this->repo->findMostRecentOrphanVisit()); $this->assertNull($this->repo->findMostRecentOrphanVisit());
@@ -472,7 +475,7 @@ class VisitRepositoryTest extends DatabaseTestCase
?ApiKey $apiKey = null, ?ApiKey $apiKey = null,
): array { ): array {
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => 'longUrl', ShortUrlInputFilter::LONG_URL => 'https://longUrl',
ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::TAGS => $tags,
ShortUrlInputFilter::API_KEY => $apiKey, ShortUrlInputFilter::API_KEY => $apiKey,
]), $this->relationResolver); ]), $this->relationResolver);
@@ -486,7 +489,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([ $shortUrlWithDomain = ShortUrl::create(ShortUrlCreation::fromRawData([
'customSlug' => $shortCode, 'customSlug' => $shortCode,
'domain' => $domain, 'domain' => $domain,
'longUrl' => 'longUrl', 'longUrl' => 'https://longUrl',
])); ]));
$this->getEntityManager()->persist($shortUrlWithDomain); $this->getEntityManager()->persist($shortUrlWithDomain);
$this->createVisitsForShortUrl($shortUrlWithDomain, 3); $this->createVisitsForShortUrl($shortUrlWithDomain, 3);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Action; namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
@@ -29,7 +30,7 @@ class PixelActionTest extends TestCase
$this->action = new PixelAction($this->urlResolver, $this->requestTracker); $this->action = new PixelAction($this->urlResolver, $this->requestTracker);
} }
/** @test */ #[Test]
public function imageIsReturned(): void public function imageIsReturned(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';

View File

@@ -7,6 +7,8 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@@ -37,7 +39,7 @@ class QrCodeActionTest extends TestCase
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class); $this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
} }
/** @test */ #[Test]
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -50,7 +52,7 @@ class QrCodeActionTest extends TestCase
$this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate);
} }
/** @test */ #[Test]
public function aCorrectRequestReturnsTheQrCodeResponse(): void public function aCorrectRequestReturnsTheQrCodeResponse(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -66,10 +68,7 @@ class QrCodeActionTest extends TestCase
self::assertEquals(200, $resp->getStatusCode()); self::assertEquals(200, $resp->getStatusCode());
} }
/** #[Test, DataProvider('provideQueries')]
* @test
* @dataProvider provideQueries
*/
public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat( public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat(
string $defaultFormat, string $defaultFormat,
array $query, array $query,
@@ -87,7 +86,7 @@ class QrCodeActionTest extends TestCase
self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type'));
} }
public function provideQueries(): iterable public static function provideQueries(): iterable
{ {
yield 'no format, png default' => ['png', [], 'image/png']; yield 'no format, png default' => ['png', [], 'image/png'];
yield 'no format, svg default' => ['svg', [], 'image/svg+xml']; yield 'no format, svg default' => ['svg', [], 'image/svg+xml'];
@@ -99,10 +98,7 @@ class QrCodeActionTest extends TestCase
yield 'unsupported format, svg default' => ['svg', ['format' => 'jpg'], 'image/svg+xml']; yield 'unsupported format, svg default' => ['svg', ['format' => 'jpg'], 'image/svg+xml'];
} }
/** #[Test, DataProvider('provideRequestsWithSize')]
* @test
* @dataProvider provideRequestsWithSize
*/
public function imageIsReturnedWithExpectedSize( public function imageIsReturnedWithExpectedSize(
QrCodeOptions $defaultOptions, QrCodeOptions $defaultOptions,
ServerRequestInterface $req, ServerRequestInterface $req,
@@ -122,7 +118,7 @@ class QrCodeActionTest extends TestCase
self::assertEquals($expectedSize, $size); self::assertEquals($expectedSize, $size);
} }
public function provideRequestsWithSize(): iterable public static function provideRequestsWithSize(): iterable
{ {
yield 'different margin and size defaults' => [ yield 'different margin and size defaults' => [
new QrCodeOptions(size: 660, margin: 40), new QrCodeOptions(size: 660, margin: 40),
@@ -188,10 +184,7 @@ class QrCodeActionTest extends TestCase
]; ];
} }
/** #[Test, DataProvider('provideRoundBlockSize')]
* @test
* @dataProvider provideRoundBlockSize
*/
public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled( public function imageCanRemoveExtraMarginWhenBlockRoundIsDisabled(
QrCodeOptions $defaultOptions, QrCodeOptions $defaultOptions,
?string $roundBlockSize, ?string $roundBlockSize,
@@ -215,7 +208,7 @@ class QrCodeActionTest extends TestCase
self::assertEquals($color, $expectedColor); self::assertEquals($color, $expectedColor);
} }
public function provideRoundBlockSize(): iterable public static function provideRoundBlockSize(): iterable
{ {
yield 'no round block param' => [new QrCodeOptions(), null, self::WHITE]; yield 'no round block param' => [new QrCodeOptions(), null, self::WHITE];
yield 'no round block param, but disabled by default' => [ yield 'no round block param, but disabled by default' => [

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
@@ -44,7 +45,7 @@ class RedirectActionTest extends TestCase
); );
} }
/** @test */ #[Test]
public function redirectionIsPerformedToLongUrl(): void public function redirectionIsPerformedToLongUrl(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
@@ -64,7 +65,7 @@ class RedirectActionTest extends TestCase
self::assertSame($expectedResp, $response); self::assertSame($expectedResp, $response);
} }
/** @test */ #[Test]
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Action; namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Action\RobotsAction; use Shlinkio\Shlink\Core\Action\RobotsAction;
@@ -21,10 +23,7 @@ class RobotsActionTest extends TestCase
$this->action = new RobotsAction($this->helper); $this->action = new RobotsAction($this->helper);
} }
/** #[Test, DataProvider('provideShortCodes')]
* @test
* @dataProvider provideShortCodes
*/
public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void
{ {
$this->helper $this->helper
@@ -39,7 +38,7 @@ class RobotsActionTest extends TestCase
self::assertEquals('text/plain', $response->getHeaderLine('Content-Type')); self::assertEquals('text/plain', $response->getHeaderLine('Content-Type'));
} }
public function provideShortCodes(): iterable public static function provideShortCodes(): iterable
{ {
yield 'three short codes' => [['foo', 'bar', 'baz'], <<<ROBOTS yield 'three short codes' => [['foo', 'bar', 'baz'], <<<ROBOTS
# For more information about the robots.txt standard, see: # For more information about the robots.txt standard, see:

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config; namespace ShlinkioTest\Shlink\Core\Config;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig; use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig;
@@ -16,7 +17,7 @@ class EmptyNotFoundRedirectConfigTest extends TestCase
$this->redirectsConfig = new EmptyNotFoundRedirectConfig(); $this->redirectsConfig = new EmptyNotFoundRedirectConfig();
} }
/** @test */ #[Test]
public function allMethodsReturnHardcodedValues(): void public function allMethodsReturnHardcodedValues(): void
{ {
self::assertNull($this->redirectsConfig->invalidShortUrlRedirect()); self::assertNull($this->redirectsConfig->invalidShortUrlRedirect());

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config; namespace ShlinkioTest\Shlink\Core\Config;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Config\EnvVars;
@@ -23,16 +25,13 @@ class EnvVarsTest extends TestCase
putenv(EnvVars::DB_NAME->value . '='); putenv(EnvVars::DB_NAME->value . '=');
} }
/** #[Test, DataProvider('provideExistingEnvVars')]
* @test
* @dataProvider provideExistingEnvVars
*/
public function existsInEnvReturnsExpectedValue(EnvVars $envVar, bool $exists): void public function existsInEnvReturnsExpectedValue(EnvVars $envVar, bool $exists): void
{ {
self::assertEquals($exists, $envVar->existsInEnv()); self::assertEquals($exists, $envVar->existsInEnv());
} }
public function provideExistingEnvVars(): iterable public static function provideExistingEnvVars(): iterable
{ {
yield 'DB_NAME' => [EnvVars::DB_NAME, true]; yield 'DB_NAME' => [EnvVars::DB_NAME, true];
yield 'BASE_PATH' => [EnvVars::BASE_PATH, true]; yield 'BASE_PATH' => [EnvVars::BASE_PATH, true];
@@ -40,16 +39,13 @@ class EnvVarsTest extends TestCase
yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT, false]; yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT, false];
} }
/** #[Test, DataProvider('provideEnvVarsValues')]
* @test
* @dataProvider provideEnvVarsValues
*/
public function expectedValueIsLoadedFromEnv(EnvVars $envVar, mixed $expected, mixed $default): void public function expectedValueIsLoadedFromEnv(EnvVars $envVar, mixed $expected, mixed $default): void
{ {
self::assertEquals($expected, $envVar->loadFromEnv($default)); self::assertEquals($expected, $envVar->loadFromEnv($default));
} }
public function provideEnvVarsValues(): iterable public static function provideEnvVarsValues(): iterable
{ {
yield 'DB_NAME without default' => [EnvVars::DB_NAME, 'shlink', null]; yield 'DB_NAME without default' => [EnvVars::DB_NAME, 'shlink', null];
yield 'DB_NAME with default' => [EnvVars::DB_NAME, 'shlink', 'foobar']; yield 'DB_NAME with default' => [EnvVars::DB_NAME, 'shlink', 'foobar'];

View File

@@ -9,11 +9,12 @@ use Laminas\Diactoros\ServerRequestFactory;
use Laminas\Diactoros\Uri; use Laminas\Diactoros\Uri;
use Mezzio\Router\Route; use Mezzio\Router\Route;
use Mezzio\Router\RouteResult; use Mezzio\Router\RouteResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver; use Shlinkio\Shlink\Core\Config\NotFoundRedirectResolver;
@@ -21,6 +22,8 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use function Laminas\Stratigility\middleware;
class NotFoundRedirectResolverTest extends TestCase class NotFoundRedirectResolverTest extends TestCase
{ {
private NotFoundRedirectResolver $resolver; private NotFoundRedirectResolver $resolver;
@@ -32,10 +35,7 @@ class NotFoundRedirectResolverTest extends TestCase
$this->resolver = new NotFoundRedirectResolver($this->helper, new NullLogger()); $this->resolver = new NotFoundRedirectResolver($this->helper, new NullLogger());
} }
/** #[Test, DataProvider('provideRedirects')]
* @test
* @dataProvider provideRedirects
*/
public function expectedRedirectionIsReturnedDependingOnTheCase( public function expectedRedirectionIsReturnedDependingOnTheCase(
UriInterface $uri, UriInterface $uri,
NotFoundType $notFoundType, NotFoundType $notFoundType,
@@ -52,47 +52,47 @@ class NotFoundRedirectResolverTest extends TestCase
self::assertSame($expectedResp, $resp); self::assertSame($expectedResp, $resp);
} }
public function provideRedirects(): iterable public static function provideRedirects(): iterable
{ {
yield 'base URL with trailing slash' => [ yield 'base URL with trailing slash' => [
$uri = new Uri('/'), $uri = new Uri('/'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(baseUrl: 'baseUrl'), new NotFoundRedirectOptions(baseUrl: 'baseUrl'),
'baseUrl', 'baseUrl',
]; ];
yield 'base URL with domain placeholder' => [ yield 'base URL with domain placeholder' => [
$uri = new Uri('https://s.test'), $uri = new Uri('https://s.test'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/{DOMAIN}'),
'https://redirect-here.com/s.test', 'https://redirect-here.com/s.test',
]; ];
yield 'base URL with domain placeholder in query' => [ yield 'base URL with domain placeholder in query' => [
$uri = new Uri('https://s.test'), $uri = new Uri('https://s.test'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'),
'https://redirect-here.com/?domain=s.test', 'https://redirect-here.com/?domain=s.test',
]; ];
yield 'base URL without trailing slash' => [ yield 'base URL without trailing slash' => [
$uri = new Uri(''), $uri = new Uri(''),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(baseUrl: 'baseUrl'), new NotFoundRedirectOptions(baseUrl: 'baseUrl'),
'baseUrl', 'baseUrl',
]; ];
yield 'regular 404' => [ yield 'regular 404' => [
$uri = new Uri('/foo/bar'), $uri = new Uri('/foo/bar'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(regular404: 'regular404'), new NotFoundRedirectOptions(regular404: 'regular404'),
'regular404', 'regular404',
]; ];
yield 'regular 404 with path placeholder in query' => [ yield 'regular 404 with path placeholder in query' => [
$uri = new Uri('/foo/bar'), $uri = new Uri('/foo/bar'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions(regular404: 'https://redirect-here.com/?path={ORIGINAL_PATH}'), new NotFoundRedirectOptions(regular404: 'https://redirect-here.com/?path={ORIGINAL_PATH}'),
'https://redirect-here.com/?path=%2Ffoo%2Fbar', 'https://redirect-here.com/?path=%2Ffoo%2Fbar',
]; ];
yield 'regular 404 with multiple placeholders' => [ yield 'regular 404 with multiple placeholders' => [
$uri = new Uri('https://s.test/foo/bar'), $uri = new Uri('https://s.test/foo/bar'),
$this->notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)),
new NotFoundRedirectOptions( new NotFoundRedirectOptions(
regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}', regular404: 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}',
), ),
@@ -100,22 +100,22 @@ class NotFoundRedirectResolverTest extends TestCase
]; ];
yield 'invalid short URL' => [ yield 'invalid short URL' => [
new Uri('/foo'), new Uri('/foo'),
$this->notFoundType($this->requestForRoute(RedirectAction::class)), self::notFoundType(self::requestForRoute(RedirectAction::class)),
new NotFoundRedirectOptions(invalidShortUrl: 'invalidShortUrl'), new NotFoundRedirectOptions(invalidShortUrl: 'invalidShortUrl'),
'invalidShortUrl', 'invalidShortUrl',
]; ];
yield 'invalid short URL with path placeholder' => [ yield 'invalid short URL with path placeholder' => [
new Uri('/foo'), new Uri('/foo'),
$this->notFoundType($this->requestForRoute(RedirectAction::class)), self::notFoundType(self::requestForRoute(RedirectAction::class)),
new NotFoundRedirectOptions(invalidShortUrl: 'https://redirect-here.com/{ORIGINAL_PATH}'), new NotFoundRedirectOptions(invalidShortUrl: 'https://redirect-here.com/{ORIGINAL_PATH}'),
'https://redirect-here.com/foo', 'https://redirect-here.com/foo',
]; ];
} }
/** @test */ #[Test]
public function noResponseIsReturnedIfNoConditionsMatch(): void public function noResponseIsReturnedIfNoConditionsMatch(): void
{ {
$notFoundType = $this->notFoundType($this->requestForRoute('foo')); $notFoundType = self::notFoundType(self::requestForRoute('foo'));
$this->helper->expects($this->never())->method('buildRedirectResponse'); $this->helper->expects($this->never())->method('buildRedirectResponse');
$result = $this->resolver->resolveRedirectResponse($notFoundType, new NotFoundRedirectOptions(), new Uri()); $result = $this->resolver->resolveRedirectResponse($notFoundType, new NotFoundRedirectOptions(), new Uri());
@@ -123,12 +123,12 @@ class NotFoundRedirectResolverTest extends TestCase
self::assertNull($result); self::assertNull($result);
} }
private function notFoundType(ServerRequestInterface $req): NotFoundType private static function notFoundType(ServerRequestInterface $req): NotFoundType
{ {
return NotFoundType::fromRequest($req, ''); return NotFoundType::fromRequest($req, '');
} }
private function requestForRoute(string $routeName): ServerRequestInterface private static function requestForRoute(string $routeName): ServerRequestInterface
{ {
return ServerRequestFactory::fromGlobals() return ServerRequestFactory::fromGlobals()
->withAttribute( ->withAttribute(
@@ -136,7 +136,8 @@ class NotFoundRedirectResolverTest extends TestCase
RouteResult::fromRoute( RouteResult::fromRoute(
new Route( new Route(
'foo', 'foo',
$this->createMock(MiddlewareInterface::class), middleware(function (): void {
}),
['GET'], ['GET'],
$routeName, $routeName,
), ),

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; namespace ShlinkioTest\Shlink\Core\Config\PostProcessor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\PostProcessor\BasePathPrefixer; use Shlinkio\Shlink\Core\Config\PostProcessor\BasePathPrefixer;
@@ -16,10 +18,7 @@ class BasePathPrefixerTest extends TestCase
$this->prefixer = new BasePathPrefixer(); $this->prefixer = new BasePathPrefixer();
} }
/** #[Test, DataProvider('provideConfig')]
* @test
* @dataProvider provideConfig
*/
public function parsesConfigAsExpected( public function parsesConfigAsExpected(
array $originalConfig, array $originalConfig,
array $expectedRoutes, array $expectedRoutes,
@@ -31,7 +30,7 @@ class BasePathPrefixerTest extends TestCase
self::assertEquals($expectedMiddlewares, $middlewares); self::assertEquals($expectedMiddlewares, $middlewares);
} }
public function provideConfig(): iterable public static function provideConfig(): iterable
{ {
yield 'with empty options' => [['routes' => []], [], []]; yield 'with empty options' => [['routes' => []], [], []];
yield 'with non-empty options' => [ yield 'with non-empty options' => [

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; namespace ShlinkioTest\Shlink\Core\Config\PostProcessor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\PostProcessor\MultiSegmentSlugProcessor; use Shlinkio\Shlink\Core\Config\PostProcessor\MultiSegmentSlugProcessor;
@@ -16,16 +18,13 @@ class MultiSegmentSlugProcessorTest extends TestCase
$this->processor = new MultiSegmentSlugProcessor(); $this->processor = new MultiSegmentSlugProcessor();
} }
/** #[Test, DataProvider('provideConfigs')]
* @test
* @dataProvider provideConfigs
*/
public function parsesRoutesAsExpected(array $config, array $expectedRoutes): void public function parsesRoutesAsExpected(array $config, array $expectedRoutes): void
{ {
self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? []); self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? []);
} }
public function provideConfigs(): iterable public static function provideConfigs(): iterable
{ {
yield [[], []]; yield [[], []];
yield [['url_shortener' => []], []]; yield [['url_shortener' => []], []];

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Config\PostProcessor; namespace ShlinkioTest\Shlink\Core\Config\PostProcessor;
use Mezzio\Router\Route; use Mezzio\Router\Route;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Config\PostProcessor\ShortUrlMethodsProcessor; use Shlinkio\Shlink\Core\Config\PostProcessor\ShortUrlMethodsProcessor;
@@ -18,10 +20,7 @@ class ShortUrlMethodsProcessorTest extends TestCase
$this->processor = new ShortUrlMethodsProcessor(); $this->processor = new ShortUrlMethodsProcessor();
} }
/** #[Test, DataProvider('provideConfigs')]
* @test
* @dataProvider provideConfigs
*/
public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods( public function onlyFirstRouteIdentifiedAsRedirectIsEditedWithProperAllowedMethods(
array $config, array $config,
?array $expectedRoutes, ?array $expectedRoutes,
@@ -29,7 +28,7 @@ class ShortUrlMethodsProcessorTest extends TestCase
self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null); self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? null);
} }
public function provideConfigs(): iterable public static function provideConfigs(): iterable
{ {
$buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[ $buildConfigWithStatus = static fn (int $status, ?array $expectedAllowedMethods) => [[
'routes' => [ 'routes' => [

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core; namespace ShlinkioTest\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\ConfigProvider; use Shlinkio\Shlink\Core\ConfigProvider;
@@ -17,7 +18,7 @@ class ConfigProviderTest extends TestCase
$this->configProvider = new ConfigProvider(); $this->configProvider = new ConfigProvider();
} }
/** @test */ #[Test]
public function properConfigIsReturned(): void public function properConfigIsReturned(): void
{ {
$config = ($this->configProvider)(); $config = ($this->configProvider)();

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Crawling; namespace ShlinkioTest\Shlink\Core\Crawling;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Crawling\CrawlingHelper; use Shlinkio\Shlink\Core\Crawling\CrawlingHelper;
@@ -20,7 +21,7 @@ class CrawlingHelperTest extends TestCase
$this->helper = new CrawlingHelper($this->query); $this->helper = new CrawlingHelper($this->query);
} }
/** @test */ #[Test]
public function listCrawlableShortCodesDelegatesIntoRepository(): void public function listCrawlableShortCodesDelegatesIntoRepository(): void
{ {
$this->query->expects($this->once())->method('__invoke')->willReturn([]); $this->query->expects($this->once())->method('__invoke')->willReturn([]);

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Domain; namespace ShlinkioTest\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig; use Shlinkio\Shlink\Core\Config\EmptyNotFoundRedirectConfig;
@@ -29,10 +31,7 @@ class DomainServiceTest extends TestCase
$this->domainService = new DomainService($this->em, 'default.com'); $this->domainService = new DomainService($this->em, 'default.com');
} }
/** #[Test, DataProvider('provideExcludedDomains')]
* @test
* @dataProvider provideExcludedDomains
*/
public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void
{ {
$repo = $this->createMock(DomainRepositoryInterface::class); $repo = $this->createMock(DomainRepositoryInterface::class);
@@ -44,7 +43,7 @@ class DomainServiceTest extends TestCase
self::assertEquals($expectedResult, $result); self::assertEquals($expectedResult, $result);
} }
public function provideExcludedDomains(): iterable public static function provideExcludedDomains(): iterable
{ {
$default = DomainItem::forDefaultDomain('default.com', new EmptyNotFoundRedirectConfig()); $default = DomainItem::forDefaultDomain('default.com', new EmptyNotFoundRedirectConfig());
$adminApiKey = ApiKey::create(); $adminApiKey = ApiKey::create();
@@ -102,7 +101,7 @@ class DomainServiceTest extends TestCase
]; ];
} }
/** @test */ #[Test]
public function getDomainThrowsExceptionWhenDomainIsNotFound(): void public function getDomainThrowsExceptionWhenDomainIsNotFound(): void
{ {
$this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn(null); $this->em->expects($this->once())->method('find')->with(Domain::class, '123')->willReturn(null);
@@ -112,7 +111,7 @@ class DomainServiceTest extends TestCase
$this->domainService->getDomain('123'); $this->domainService->getDomain('123');
} }
/** @test */ #[Test]
public function getDomainReturnsEntityWhenFound(): void public function getDomainReturnsEntityWhenFound(): void
{ {
$domain = Domain::withAuthority(''); $domain = Domain::withAuthority('');
@@ -123,10 +122,7 @@ class DomainServiceTest extends TestCase
self::assertSame($domain, $result); self::assertSame($domain, $result);
} }
/** #[Test, DataProvider('provideFoundDomains')]
* @test
* @dataProvider provideFoundDomains
*/
public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void
{ {
$authority = 'example.com'; $authority = 'example.com';
@@ -145,7 +141,7 @@ class DomainServiceTest extends TestCase
} }
} }
/** @test */ #[Test]
public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void public function getOrCreateThrowsExceptionForApiKeysWithDomainRole(): void
{ {
$authority = 'example.com'; $authority = 'example.com';
@@ -163,10 +159,7 @@ class DomainServiceTest extends TestCase
$this->domainService->getOrCreate($authority, $apiKey); $this->domainService->getOrCreate($authority, $apiKey);
} }
/** #[Test, DataProvider('provideFoundDomains')]
* @test
* @dataProvider provideFoundDomains
*/
public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void
{ {
$authority = 'example.com'; $authority = 'example.com';
@@ -190,7 +183,7 @@ class DomainServiceTest extends TestCase
self::assertEquals('baz.com', $result->invalidShortUrlRedirect()); self::assertEquals('baz.com', $result->invalidShortUrlRedirect());
} }
public function provideFoundDomains(): iterable public static function provideFoundDomains(): iterable
{ {
$domain = Domain::withAuthority(''); $domain = Domain::withAuthority('');
$adminApiKey = ApiKey::create(); $adminApiKey = ApiKey::create();

View File

@@ -6,6 +6,9 @@ namespace ShlinkioTest\Shlink\Core\ErrorHandler;
use Laminas\Diactoros\Response; use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
@@ -42,10 +45,7 @@ class NotFoundRedirectHandlerTest extends TestCase
); );
} }
/** #[Test, DataProvider('provideNonRedirectScenarios')]
* @test
* @dataProvider provideNonRedirectScenarios
*/
public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void public function nextIsCalledWhenNoRedirectIsResolved(callable $setUp): void
{ {
$expectedResp = new Response(); $expectedResp = new Response();
@@ -58,44 +58,43 @@ class NotFoundRedirectHandlerTest extends TestCase
self::assertSame($expectedResp, $result); self::assertSame($expectedResp, $result);
} }
public function provideNonRedirectScenarios(): iterable public static function provideNonRedirectScenarios(): iterable
{ {
yield 'no domain' => [function ( yield 'no domain' => [function (
MockObject&DomainServiceInterface $domainService, MockObject&DomainServiceInterface $domainService,
MockObject&NotFoundRedirectResolverInterface $resolver, MockObject&NotFoundRedirectResolverInterface $resolver,
): void { ): void {
$domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn( $domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn(
null, null,
); );
$resolver->expects($this->once())->method('resolveRedirectResponse')->with( $resolver->expects(self::once())->method('resolveRedirectResponse')->with(
$this->isInstanceOf(NotFoundType::class), self::isInstanceOf(NotFoundType::class),
$this->isInstanceOf(NotFoundRedirectOptions::class), self::isInstanceOf(NotFoundRedirectOptions::class),
$this->isInstanceOf(UriInterface::class), self::isInstanceOf(UriInterface::class),
)->willReturn(null); )->willReturn(null);
}]; }];
yield 'non-redirecting domain' => [function ( yield 'non-redirecting domain' => [function (
MockObject&DomainServiceInterface $domainService, MockObject&DomainServiceInterface $domainService,
MockObject&NotFoundRedirectResolverInterface $resolver, MockObject&NotFoundRedirectResolverInterface $resolver,
): void { ): void {
$domainService->expects($this->once())->method('findByAuthority')->withAnyParameters()->willReturn( $domainService->expects(self::once())->method('findByAuthority')->withAnyParameters()->willReturn(
Domain::withAuthority(''), Domain::withAuthority(''),
); );
$resolver->expects($this->exactly(2))->method('resolveRedirectResponse')->withConsecutive( $callCount = 0;
[ $resolver->expects(self::exactly(2))->method('resolveRedirectResponse')->willReturnCallback(
$this->isInstanceOf(NotFoundType::class), function (mixed $arg1, mixed $arg2, mixed $arg3) use (&$callCount) {
$this->isInstanceOf(Domain::class), Assert::assertInstanceOf(NotFoundType::class, $arg1);
$this->isInstanceOf(UriInterface::class), Assert::assertInstanceOf($callCount === 0 ? Domain::class : NotFoundRedirectOptions::class, $arg2);
], Assert::assertInstanceOf(UriInterface::class, $arg3);
[
$this->isInstanceOf(NotFoundType::class), $callCount++;
$this->isInstanceOf(NotFoundRedirectOptions::class), return null;
$this->isInstanceOf(UriInterface::class), },
], );
)->willReturn(null);
}]; }];
} }
/** @test */ #[Test]
public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void public function globalRedirectIsUsedIfDomainRedirectIsNotFound(): void
{ {
$expectedResp = new Response(); $expectedResp = new Response();
@@ -113,7 +112,7 @@ class NotFoundRedirectHandlerTest extends TestCase
self::assertSame($expectedResp, $result); self::assertSame($expectedResp, $result);
} }
/** @test */ #[Test]
public function domainRedirectIsUsedIfFound(): void public function domainRedirectIsUsedIfFound(): void
{ {
$expectedResp = new Response(); $expectedResp = new Response();

View File

@@ -9,13 +9,16 @@ use Laminas\Diactoros\ServerRequestFactory;
use Laminas\Diactoros\Uri; use Laminas\Diactoros\Uri;
use Mezzio\Router\Route; use Mezzio\Router\Route;
use Mezzio\Router\RouteResult; use Mezzio\Router\RouteResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler; use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler;
use function Laminas\Stratigility\middleware;
class NotFoundTemplateHandlerTest extends TestCase class NotFoundTemplateHandlerTest extends TestCase
{ {
private NotFoundTemplateHandler $handler; private NotFoundTemplateHandler $handler;
@@ -31,10 +34,7 @@ class NotFoundTemplateHandlerTest extends TestCase
$this->handler = new NotFoundTemplateHandler($readFile); $this->handler = new NotFoundTemplateHandler($readFile);
} }
/** #[Test, DataProvider('provideTemplates')]
* @test
* @dataProvider provideTemplates
*/
public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void
{ {
$resp = $this->handler->handle($request->withHeader('Accept', 'text/html')); $resp = $this->handler->handle($request->withHeader('Accept', 'text/html'));
@@ -44,19 +44,20 @@ class NotFoundTemplateHandlerTest extends TestCase
self::assertTrue($this->readFileCalled); self::assertTrue($this->readFileCalled);
} }
public function provideTemplates(): iterable public static function provideTemplates(): iterable
{ {
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo')); $request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo'));
yield 'base url' => [$this->withNotFoundType($request, '/foo'), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; yield 'base url' => [self::withNotFoundType($request, '/foo'), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
yield 'regular not found' => [$this->withNotFoundType($request), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE]; yield 'regular not found' => [self::withNotFoundType($request), NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
yield 'invalid short code' => [ yield 'invalid short code' => [
$this->withNotFoundType($request->withAttribute( self::withNotFoundType($request->withAttribute(
RouteResult::class, RouteResult::class,
RouteResult::fromRoute( RouteResult::fromRoute(
new Route( new Route(
'foo', 'foo',
$this->createMock(MiddlewareInterface::class), middleware(function (): void {
}),
['GET'], ['GET'],
RedirectAction::class, RedirectAction::class,
), ),
@@ -66,7 +67,7 @@ class NotFoundTemplateHandlerTest extends TestCase
]; ];
} }
private function withNotFoundType(ServerRequestInterface $req, string $baseUrl = ''): ServerRequestInterface private static function withNotFoundType(ServerRequestInterface $req, string $baseUrl = ''): ServerRequestInterface
{ {
$type = NotFoundType::fromRequest($req, $baseUrl); $type = NotFoundType::fromRequest($req, $baseUrl);
return $req->withAttribute(NotFoundType::class, $type); return $req->withAttribute(NotFoundType::class, $type);

Some files were not shown because too many files have changed in this diff Show More