Compare commits

...

151 Commits

Author SHA1 Message Date
Alejandro Celaya
4cf3bc08f9 Merge pull request #1883 from shlinkio/release/v3.6.4
Release 3.6.4
2023-09-23 08:57:10 +02:00
Alejandro Celaya
7e093a3fd8 Fix date in changelog 2023-09-23 08:41:57 +02:00
Alejandro Celaya
abecf3be02 Merge pull request #1882 from acelaya-forks/feature/create-api-key
Feature/create api key
2023-09-23 08:40:53 +02:00
Alejandro Celaya
3d9b48c5fd Create InitialApiKeyCommand cli test 2023-09-23 08:28:57 +02:00
Alejandro Celaya
ba4a66f772 Add InitialApiKeyCommand unit test 2023-09-23 08:16:22 +02:00
Alejandro Celaya
ec839183e8 Add unit test for ApiKeyService::createInitial 2023-09-23 08:01:10 +02:00
Alejandro Celaya
b0ec0601c1 Update to latest shlink-installer 2023-09-22 10:00:19 +02:00
Alejandro Celaya
637d8334f4 New CLI command to create the initial API key idempotently 2023-09-21 09:47:21 +02:00
Alejandro Celaya
6db46b50e9 Roll back change to allow creating API keys with custom value 2023-09-21 08:58:05 +02:00
Alejandro Celaya
f6b1cc7556 Test API key creation with custom key 2023-09-19 10:14:04 +02:00
Alejandro Celaya
65a0a90a51 Allow custom API keys to be created 2023-09-19 09:10:17 +02:00
Alejandro Celaya
49bd230474 Merge pull request #1874 from acelaya-forks/feature/redis-lock-namespace
Make sure locks include the same cache namespace when sent to Redis
2023-09-12 21:44:45 +02:00
Alejandro Celaya
074f2135f6 Make sure locks include the same cache namespace when sent to Redis 2023-09-12 21:20:38 +02:00
Alejandro Celaya
ef073d59ca Merge pull request #1872 from acelaya-forks/bugfix/db-commands-timeout
Fix incorrect timeout in init commands
2023-09-12 08:33:13 +02:00
Alejandro Celaya
a3b2f94339 Make sure local config is not loaded in tests 2023-09-12 08:21:34 +02:00
Alejandro Celaya
b17c576a30 Fix incorrect timeout in init commands 2023-09-11 09:07:18 +02:00
Alejandro Celaya
228bd83b75 Merge pull request #1818 from acelaya-forks/feature/fix-sqlite-db-creation
Fix Shlink trying to create SQLite database tables even if they already exist
2023-06-14 18:22:39 +02:00
Alejandro Celaya
a21dcb852a Fix Shlink trying to create SQLite database tables even if they already exist 2023-06-14 18:08:39 +02:00
Alejandro Celaya
058391cf06 Merge pull request #1809 from acelaya-forks/feature/fix-rr-download
Update to a shlink-installer version that fixes rr download
2023-06-08 18:59:28 +02:00
Alejandro Celaya
24e6acc6e8 Update to a shlink-installer version that fixes rr download 2023-06-08 18:47:55 +02:00
Alejandro Celaya
56d299a7dc Merge pull request #1804 from acelaya-forks/feature/release-3.6.1
Feature/release 3.6.1
2023-06-04 09:30:19 +02:00
Alejandro Celaya
575e6bf707 Downgrade PHPUnit to avoid infection error 2023-06-04 09:13:37 +02:00
Alejandro Celaya
e50c21440f Define default values for env vars used in rr prod config 2023-06-04 09:07:41 +02:00
Alejandro Celaya
7cff11080d Update changelog 2023-06-04 08:57:07 +02:00
Alejandro Celaya
72381f9844 Change order to create initial database to avoid permission errors 2023-06-04 08:54:08 +02:00
Alejandro Celaya
b6792d3fb8 Merge pull request #1792 from shlinkio/develop
Release 3.6.0
2023-05-24 08:46:25 +02:00
Alejandro Celaya
2f0d658432 Merge pull request #1791 from acelaya-forks/feature/fix-cpu-100
Update changelog
2023-05-23 23:25:39 +02:00
Alejandro Celaya
8c1865c3ec Update changelog 2023-05-23 23:15:59 +02:00
Alejandro Celaya
096d2098d6 Update installer 2023-05-23 18:42:50 +02:00
Alejandro Celaya
882d64ae11 Add deprecation note for ENABLE_PERIODIC_VISIT_LOCATE env var 2023-05-23 10:55:49 +02:00
Alejandro Celaya
3352bcd186 Merge pull request #1789 from acelaya-forks/feature/improved-dependency-locks
Feature/improved dependency locks
2023-05-21 18:44:10 +02:00
Alejandro Celaya
9743c1624d Update changelog 2023-05-21 18:10:08 +02:00
Alejandro Celaya
e85d59c5a4 Add locks when creating short URL dependencies, to avoid race condition 2023-05-21 18:08:17 +02:00
Alejandro Celaya
ac0ff8fb94 Merge pull request #1787 from acelaya-forks/feature/shlink-init-command
Feature/shlink init command
2023-05-21 14:44:08 +02:00
Alejandro Celaya
90f93ee4ec Update changelog 2023-05-21 14:32:00 +02:00
Alejandro Celaya
794d926e3a Update docker entry point to use new shlink-installer init command 2023-05-21 14:30:20 +02:00
Alejandro Celaya
bd41ebef9f Merge pull request #1785 from acelaya-forks/feature/non-root-support
Allow running docker container as non-root
2023-05-19 20:29:40 +02:00
Alejandro Celaya
725370704f Update changelog 2023-05-19 19:50:05 +02:00
Alejandro Celaya
f03b7689ce Allow running docker container as non-root 2023-05-19 19:48:20 +02:00
Alejandro Celaya
fb31e2a5e4 Merge pull request #1782 from acelaya-forks/feature/clear-orphan-visits
Feature/clear orphan visits
2023-05-18 09:49:31 +02:00
Alejandro Celaya
d688c6da7e Update changelog 2023-05-18 09:36:50 +02:00
Alejandro Celaya
618784dc3b Create command to delete all orphan visits 2023-05-18 09:35:42 +02:00
Alejandro Celaya
9d64d4ed1d Create abstract base class for commands deleting visits 2023-05-18 09:33:15 +02:00
Alejandro Celaya
7f02243c6c Rename short-url:delete-visits to short-url:visits-delete for consistency with other commands 2023-05-18 09:19:01 +02:00
Alejandro Celaya
3916c68126 Add DeleteOrphanVisitsTest API test 2023-05-18 09:09:44 +02:00
Alejandro Celaya
a6f0c66331 Document endpoint to delete orphan visits 2023-05-18 09:06:52 +02:00
Alejandro Celaya
bdfb220126 Create REST action to delete orphan visits 2023-05-18 09:04:28 +02:00
Alejandro Celaya
abcf2f86be Create service to delete orphan visits 2023-05-18 09:01:57 +02:00
Alejandro Celaya
a4d8ebdfc9 Create DB logic to delete orphan visits 2023-05-18 08:58:07 +02:00
Alejandro Celaya
b51c149c30 Merge pull request #1779 from acelaya-forks/feature/clear-short-url-visits
Feature/clear short url visits
2023-05-17 09:20:28 +02:00
Alejandro Celaya
39095a3098 Fix coding styles 2023-05-17 08:57:36 +02:00
Alejandro Celaya
765199727e Update changelog 2023-05-16 09:29:22 +02:00
Alejandro Celaya
c7043af853 Create DeleteShortUrlVisitsCommandTest 2023-05-16 09:26:29 +02:00
Alejandro Celaya
02a8ef7dd9 Create DeleteShortUrlVisitsCommand 2023-05-15 09:48:24 +02:00
Alejandro Celaya
6bb8c1b2f5 Rename CLI Option namespace to Input 2023-05-15 09:02:23 +02:00
Alejandro Celaya
3cf253fd0f Document short URLs visits deletion endpoint 2023-05-14 18:25:27 +02:00
Alejandro Celaya
0365728337 Create DeleteShortUrlVisitsTest 2023-05-14 13:35:15 +02:00
Alejandro Celaya
b8143a5bb4 Create VisitDeleterRepositoryTest 2023-05-14 13:04:45 +02:00
Alejandro Celaya
531a19dde9 Refactor short URL visits deletion layers 2023-05-14 13:04:17 +02:00
Alejandro Celaya
69ff7de481 Create ShortUrlVisitsDeleterTest 2023-05-14 12:32:54 +02:00
Alejandro Celaya
ffc0555c7c Create DeleteShortUrlVisitsActionTest 2023-05-14 12:15:35 +02:00
Alejandro Celaya
84a7981dfa Create REST action to delete short URL visits 2023-05-14 12:00:08 +02:00
Alejandro Celaya
2573c2bf98 Update roadrunner config 2023-05-14 11:56:49 +02:00
Alejandro Celaya
3b4c1501f3 Set platforms to be used for openswoole docker image 2023-05-07 17:13:26 +02:00
Alejandro Celaya
e836bedecc Merge pull request #1775 from acelaya-forks/feature/default-roadrunner
Feature/default roadrunner
2023-05-07 13:34:53 +02:00
Alejandro Celaya
a797b74a70 Standardize logger for all Shlink execution contexts 2023-05-07 13:18:19 +02:00
Alejandro Celaya
ab497403ca Merge pull request #1773 from acelaya-forks/feature/rr-friendly-installer
Update shlink-installer
2023-05-06 18:07:57 +02:00
Alejandro Celaya
d4dea9a1d2 Update shlink-installer 2023-05-06 10:12:42 +02:00
Alejandro Celaya
28d93ea5e0 Update changelog 2023-05-03 08:59:47 +02:00
Alejandro Celaya
e6a31b16ed Switch to roadrunner as default docker runtime 2023-05-03 08:59:09 +02:00
Alejandro Celaya
9553192281 Merge pull request #1766 from acelaya-forks/feature/rr-cli-2.5
Update to rr-cli 2.5, and do not generate config
2023-05-02 20:01:51 +02:00
Alejandro Celaya
74069f2d24 Skip API tests fetching Twitter during CI 2023-05-02 19:51:37 +02:00
Alejandro Celaya
b4b00a57c1 Update chrome user agent used for anti-bots 2023-05-02 19:40:23 +02:00
Alejandro Celaya
a516ef691d Update to rr-cli 2.5, and do not generate config 2023-05-02 08:43:14 +02:00
Alejandro Celaya
e80b7448f5 Merge pull request #1761 from acelaya-forks/feature/null-default-domain
Feature/null default domain
2023-04-23 15:57:02 +02:00
Alejandro Celaya
f129544f83 Update changelog 2023-04-23 15:22:40 +02:00
Alejandro Celaya
9fa291a32f Update shlink-common 2023-04-23 15:20:33 +02:00
Alejandro Celaya
d06e92ffc2 Created CLI test for short URL importing 2023-04-23 13:26:59 +02:00
Alejandro Celaya
1b83344995 Create CLI test checking default domain is ignored even if explicitly provided 2023-04-23 11:20:54 +02:00
Alejandro Celaya
cf49393ef2 Add --show-domain flag to list short URLs command 2023-04-23 11:19:05 +02:00
Alejandro Celaya
f2ecbceae9 Update changelog 2023-04-22 19:46:28 +02:00
Alejandro Celaya
c582eba753 Make sure short URL domain is resolved as null when default one is provided 2023-04-22 19:44:04 +02:00
Alejandro Celaya
de86b62cdd Merge pull request #1759 from acelaya-forks/feature/fix-docker-build
Fix docker image build
2023-04-20 09:00:39 +02:00
Alejandro Celaya
73150471e9 Fix docker image build 2023-04-19 18:57:35 +02:00
Alejandro Celaya
ec751f4ac2 Merge pull request #1758 from acelaya-forks/feature/roadrunner-2023
Feature/roadrunner 2023
2023-04-19 08:11:18 +02:00
Alejandro Celaya
e652166289 Update changelog 2023-04-18 23:24:21 +02:00
Alejandro Celaya
a671d555cb Update to roadrunner 2023 2023-04-18 23:22:48 +02:00
Alejandro Celaya
6240554f4c Merge pull request #1757 from acelaya-forks/feature/shlink-json
Migrate to shlinkio/shlink-json
2023-04-18 23:14:11 +02:00
Alejandro Celaya
4ee9c9bbe3 Migrate to shlinkio/shlink-json 2023-04-18 23:04:58 +02:00
Alejandro Celaya
c830439085 Merge pull request #1752 from acelaya-forks/feature/phpunit-10.1
Update phpunit configs to fulfil v10.1
2023-04-14 09:55:38 +02:00
Alejandro Celaya
f2196583c8 Update phpunit configs to fulfil v10.1 2023-04-14 09:44:01 +02:00
Alejandro Celaya
3dbca2115c Merge pull request #1751 from acelaya-forks/feature/openswoole-22
Add support for openswoole 22
2023-04-14 09:16:47 +02:00
Alejandro Celaya
b45d8de27d Ignore openswoole dep on roadrunner tests CI 2023-04-14 09:02:17 +02:00
Alejandro Celaya
3ba46bbbfa Add support for openswoole 22 2023-04-14 08:58:54 +02:00
Alejandro Celaya
06f3f0c86c Merge pull request #1750 from acelaya-forks/feature/update-delete-artifacts
Update to geekyeggo/delete-artifact@2
2023-04-13 08:58:24 +02:00
Alejandro Celaya
06f07e3e40 Update to geekyeggo/delete-artifact@2 2023-04-12 19:13:35 +02:00
Alejandro Celaya
740740b8c6 Update to latest JamesIves/github-pages-deploy-action 2023-04-12 19:11:06 +02:00
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
330 changed files with 3830 additions and 2395 deletions

View File

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

View File

@@ -27,7 +27,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1, pdo_sqlsrv-5.10.1
php-extensions: openswoole-22.0.0, pdo_sqlsrv-5.10.1
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.platform == 'ms' }}

View File

@@ -19,7 +19,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1
php-extensions: openswoole-22.0.0
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v3
with:
@@ -27,14 +27,14 @@ jobs:
path: build
- name: Resolve 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
# run: |
# BRANCH="${GITHUB_REF#refs/heads/}" |
# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then
# echo "::set-output name=args::--logger-github=false"
# echo "args=--logger-github=false" >> $GITHUB_OUTPUT
# 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;
shell: bash
- if: ${{ inputs.test-group == 'unit' }}

View File

@@ -25,7 +25,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1
php-extensions: openswoole-22.0.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v3

View File

@@ -36,7 +36,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1
php-extensions: openswoole-22.0.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }}
@@ -69,8 +69,8 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole
- run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:api:rr
sqlite-db-tests:
@@ -152,8 +152,8 @@ jobs:
- 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-cli/coverage-cli.cov build/coverage-cli.cov
- run: wget https://phar.phpunit.de/phpcov-8.2.1.phar
- run: php phpcov-8.2.1.phar merge build --clover build/clover.xml
- run: wget https://phar.phpunit.de/phpcov-9.0.0.phar
- run: php phpcov-9.0.0.phar merge build --clover build/clover.xml
- name: Publish coverage
uses: codecov/codecov-action@v1
with:
@@ -168,10 +168,7 @@ jobs:
- upload-coverage
runs-on: ubuntu-22.04
steps:
- uses: geekyeggo/delete-artifact@v1
- uses: geekyeggo/delete-artifact@v2
with:
name: |
coverage-unit
coverage-db
coverage-api
coverage-cli
coverage-*

View File

@@ -2,8 +2,6 @@ name: Build and publish docker image
on:
push:
branches:
- develop
paths-ignore:
- 'LICENSE'
- '.*'
@@ -12,24 +10,35 @@ on:
- '*.yml*'
- '*.json5'
- '*.neon'
branches:
- develop
tags:
- 'v*'
jobs:
build-openswoole:
build-image:
strategy:
matrix:
include:
- runtime: 'rr'
platforms: 'linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'roadrunner'
platforms: 'linux/arm64/v8,linux/amd64'
- runtime: 'openswoole'
tag-suffix: 'openswoole'
platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64'
- runtime: 'rr'
tag-suffix: 'non-root'
platforms: 'linux/arm64/v8,linux/amd64'
user-id: '1001'
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink
version-arg-name: SHLINK_VERSION
build-roadrunner:
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit
with:
image-name: shlinkio/shlink
version-arg-name: SHLINK_VERSION
platforms: 'linux/arm64/v8,linux/amd64'
tags-suffix: roadrunner
platforms: ${{ matrix.platforms }}
tags-suffix: ${{ matrix.tag-suffix }}
extra-build-args: |
SHLINK_RUNTIME=rr
SHLINK_RUNTIME=${{ matrix.runtime }}
SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }}

View File

@@ -17,7 +17,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1
php-extensions: openswoole-22.0.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- if: ${{ matrix.swoole == 'yes' }}
@@ -49,11 +49,7 @@ jobs:
delete-artifacts:
needs: ['publish']
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
swoole: ['yes', 'no']
steps:
- uses: geekyeggo/delete-artifact@v1
- uses: geekyeggo/delete-artifact@v2
with:
name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
name: dist-files-*

View File

@@ -15,18 +15,18 @@ jobs:
- uses: actions/checkout@v3
- name: 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
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-4.12.1
php-extensions: openswoole-22.0.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- name: Publish spec
uses: JamesIves/github-pages-deploy-action@4.1.7
uses: JamesIves/github-pages-deploy-action@4.4.1
with:
token: ${{ secrets.OAS_PUBLISH_TOKEN }}
repository-name: 'shlinkio/shlink-open-api-specs'

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.idea
bin/.rr.*
bin/rr
config/roadrunner/.pid
build

View File

@@ -4,6 +4,184 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [3.6.4] - 2023-09-23
### Added
* *Nothing*
### Changed
* [#1866](https://github.com/shlinkio/shlink/issues/1866) The `INITIAL_API_KEY` env var is now only relevant for the official docker image.
Going forward, new non-docker Shlink installations provisioned with env vars that also wish to provide an initial API key, should do it by using the `vendor/bin/shlink-installer init --initial-api-key=%SOME_KEY%` command, instead of using `INITIAL_API_KEY`.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up.
* [#1870](https://github.com/shlinkio/shlink/issues/1870) Make sure shared locks include the cache prefix when using Redis.
* [#1866](https://github.com/shlinkio/shlink/issues/1866) Fix error when starting docker image with `INITIAL_API_KEY` env var.
## [3.6.3] - 2023-06-14
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1817](https://github.com/shlinkio/shlink/issues/1817) Fix Shlink trying to create SQLite database tables even if they already exist.
## [3.6.2] - 2023-06-08
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1808](https://github.com/shlinkio/shlink/issues/1808) Fix `rr` binary downloading during Shlink update.
## [3.6.1] - 2023-06-04
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1413](https://github.com/shlinkio/shlink/issues/1413) Fix error when creating initial DB in Postgres in a cluster where a default `postgres` db does not exist or the credentials do not grant permissions to connect.
* [#1803](https://github.com/shlinkio/shlink/issues/1803) Fix default RoadRunner port when not using docker image.
## [3.6.0] - 2023-05-24
### Added
* [#1148](https://github.com/shlinkio/shlink/issues/1148) Add support to delete short URL visits.
This can be done via `DELETE /short-urls/{shortCode}/visits` REST endpoint or via `short-url:visits-delete` console command.
The CLI command includes a warning and requires the user to confirm before proceeding.
* [#1681](https://github.com/shlinkio/shlink/issues/1681) Add support to delete orphan visits.
This can be done via `DELETE /visits/orphan` REST endpoint or via `visit:orphan-delete` console command.
The CLI command includes a warning and requires the user to confirm before proceeding.
* [#1753](https://github.com/shlinkio/shlink/issues/1753) Add a new `vendor/bin/shlink-installer init` command that can be used to automate Shlink installations.
This command can create the initial database, update it, create proxies, clean cache, download initial GeoLite db files, etc
The official docker image also uses it on its entry point script.
* [#1656](https://github.com/shlinkio/shlink/issues/1656) Add support for openswoole 22
* [#1784](https://github.com/shlinkio/shlink/issues/1784) Add new docker tag where the container runs as a non-root user.
* [#953](https://github.com/shlinkio/shlink/issues/953) Add locks that prevent errors on duplicated keys when creating short URLs in parallel that depend on the same new tag or domain.
### Changed
* [#1755](https://github.com/shlinkio/shlink/issues/1755) Update to roadrunner 2023
* [#1745](https://github.com/shlinkio/shlink/issues/1745) Roadrunner is now the default docker runtime.
There are now three different docker images published:
* Versions without suffix (like `3.6.0`) will contain the default runtime, whichever it is.
* Versions with `-roadrunner` suffix (like `3.6.0-roadrunner`) will always use roadrunner as the runtime, even if default one changes in the future.
* Versions with `-openswoole` suffix (like `3.6.0-openswoole`) will always use openswoole as the runtime, even if default one changes in the future.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1760](https://github.com/shlinkio/shlink/issues/1760) Fix domain not being set to null when importing short URLs with default domain.
* [#953](https://github.com/shlinkio/shlink/issues/953) Fix duplicated key errors and short URL creation failing when creating short URLs in parallel that depend on the same new tag or domain.
* [#1741](https://github.com/shlinkio/shlink/issues/1741) Fix randomly using 100% CPU in task workers when trying to download GeoLite DB files.
* Fix Shlink trying to connect to RabbitMQ even if configuration set to not connect.
## [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*

View File

@@ -2,13 +2,16 @@ FROM php:8.2-alpine3.17 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
ARG SHLINK_RUNTIME=openswoole
ARG SHLINK_RUNTIME=rr
ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ENV OPENSWOOLE_VERSION 4.12.1
ARG SHLINK_USER_ID='root'
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
ENV OPENSWOOLE_VERSION 22.0.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV LC_ALL "C"
ENV LC_ALL 'C'
WORKDIR /etc/shlink
@@ -43,11 +46,12 @@ FROM base as builder
COPY . .
COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \
# FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interactionc ; \
elif [ $SHLINK_RUNTIME == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \
fi; \
php composer.phar clear-cache && \
rm -r docker composer.* && \
@@ -58,10 +62,10 @@ RUN apk add --no-cache git && \
FROM base
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder /etc/shlink .
COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \
if [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; \
php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \
fi;
# Expose default port
@@ -72,14 +76,6 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
# Change the ownership of /etc/shlink/data to be writable, then change the user to non-root
# FIXME Disabled for now, as it conflicts with ENABLE_PERIODIC_VISIT_LOCATE, which is used to configure a cron as root.
# Ref: https://github.com/shlinkio/shlink/issues/1132
#RUN chown 1001 /etc/shlink/data
#RUN chown 1001 /etc/shlink/data/locks
#RUN chown 1001 /etc/shlink/data/proxies
#RUN chown 1001 /etc/shlink/data/cache
#RUN chown 1001 /etc/shlink/data/log
#USER 1001
USER ${SHLINK_USER_ID}
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View File

@@ -39,7 +39,7 @@ if [[ $noSwoole ]]; then
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs --with-all-dependencies --update-no-dev $composerFlags
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
fi
# Delete development files

View File

@@ -18,65 +18,66 @@
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "^2.3",
"cakephp/chronos": "~2.3.3",
"doctrine/migrations": "^3.5",
"doctrine/orm": "^2.13.3",
"endroid/qr-code": "^4.6",
"doctrine/orm": "^2.14",
"endroid/qr-code": "^4.7",
"geoip2/geoip2": "^2.13",
"guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.112",
"laminas/laminas-config": "^3.7",
"laminas/laminas-config-aggregator": "^1.11",
"laminas/laminas-diactoros": "^2.19",
"laminas/laminas-inputfilter": "^2.22",
"laminas/laminas-servicemanager": "^3.19",
"laminas/laminas-stdlib": "^3.15",
"lcobucci/jwt": "^4.2",
"laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-diactoros": "^2.24",
"laminas/laminas-inputfilter": "^2.24",
"laminas/laminas-servicemanager": "^3.20",
"laminas/laminas-stdlib": "^3.16",
"league/uri": "^6.8",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.13",
"mezzio/mezzio-fastroute": "^3.7",
"mezzio/mezzio-problem-details": "^1.7",
"mezzio/mezzio-swoole": "^4.5",
"mezzio/mezzio": "^3.15",
"mezzio/mezzio-fastroute": "^3.8",
"mezzio/mezzio-problem-details": "^1.11",
"mezzio/mezzio-swoole": "^4.6",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^3.74",
"ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.6",
"pagerfanta/core": "^3.7",
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.5",
"shlinkio/shlink-common": "^5.3.1",
"ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "^5.6",
"shlinkio/shlink-config": "^2.4",
"shlinkio/shlink-event-dispatcher": "^2.6",
"shlinkio/shlink-importer": "^5.0",
"shlinkio/shlink-installer": "^8.3",
"shlinkio/shlink-event-dispatcher": "^3.0",
"shlinkio/shlink-importer": "^5.1",
"shlinkio/shlink-installer": "^8.5",
"shlinkio/shlink-ip-geolocation": "^3.2",
"spiral/roadrunner": "^2.11",
"spiral/roadrunner-jobs": "^2.5",
"symfony/console": "^6.1",
"symfony/filesystem": "^6.1",
"symfony/lock": "^6.1",
"symfony/process": "^6.1",
"symfony/string": "^6.1"
"shlinkio/shlink-json": "^1.0",
"spiral/roadrunner": "^2023.1",
"spiral/roadrunner-cli": "^2.5",
"spiral/roadrunner-http": "^3.0",
"spiral/roadrunner-jobs": "^4.0",
"symfony/console": "^6.2",
"symfony/filesystem": "^6.2",
"symfony/lock": "^6.2",
"symfony/process": "^6.2",
"symfony/string": "^6.2"
},
"require-dev": {
"cebe/php-openapi": "^1.7",
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.4.0",
"infection/infection": "^0.26.15",
"openswoole/ide-helper": "~4.11.5",
"phpstan/phpstan": "^1.8",
"infection/infection": "^0.27",
"openswoole/ide-helper": "~22.0.0",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.2",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.5",
"phpunit/php-code-coverage": "^10.0",
"phpunit/phpunit": "~10.1.0",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^3.4",
"symfony/var-dumper": "^6.1",
"veewee/composer-run-parallel": "^1.1"
"shlinkio/shlink-test-utils": "~3.6.0",
"symfony/var-dumper": "^6.2",
"veewee/composer-run-parallel": "^1.2"
},
"autoload": {
"psr-4": {
@@ -109,7 +110,7 @@
"@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms",
"@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db"
],
"cs": "phpcs",
"cs": "phpcs -s",
"cs:fix": "phpcbf",
"stan": "APP_ENV=test php vendor/bin/phpstan analyse module/*/src module/*/test* module/*/config config docker/config data/migrations --level=8",
"test": [
@@ -133,7 +134,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:ci": "GENERATE_COVERAGE=yes composer test:cli",
"test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli",
"infect:ci:base": "infection --threads=max --only-covered --only-covering-test-cases --skip-initial-tests",
"infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5",

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use GuzzleHttp\Client;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Application;
use Mezzio\Container;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
@@ -20,7 +21,7 @@ return [
],
'delegators' => [
Mezzio\Application::class => [
Application::class => [
Container\ApplicationConfigInjectionDelegator::class,
],
],

View File

@@ -6,12 +6,40 @@ return [
'entity_manager' => [
'connection' => [
// MySQL
'user' => 'root',
'password' => 'root',
'driver' => 'pdo_mysql',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
// 'dbname' => 'shlink_foo',
'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

@@ -86,6 +86,9 @@ return [
InstallationCommand::API_KEY_GENERATE->value => [
'command' => 'bin/cli ' . Command\Api\GenerateKeyCommand::NAME,
],
InstallationCommand::API_KEY_CREATE->value => [
'command' => 'bin/cli ' . Command\Api\InitialApiKeyCommand::NAME,
],
],
],

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Shlinkio\Shlink\Common\Cache\RedisFactory;
use Shlinkio\Shlink\Common\Lock\NamespacedStore;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
@@ -22,11 +23,12 @@ return [
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
Lock\LockFactory::class => ConfigAbstractFactory::class,
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
NamespacedStore::class => ConfigAbstractFactory::class,
],
'aliases' => [
'lock_store' => EnvVars::REDIS_SERVERS->existsInEnv() ? 'redis_lock_store' : 'local_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'redis_lock_store' => NamespacedStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
],
'delegators' => [
@@ -39,6 +41,8 @@ return [
ConfigAbstractFactory::class => [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
NamespacedStore::class => [Lock\Store\RedisStore::class, 'config.cache.namespace'],
Lock\LockFactory::class => ['lock_store'],
LOCAL_LOCK_FACTORY => ['local_lock_store'],
],

View File

@@ -4,51 +4,63 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Monolog\Level;
use Monolog\Logger;
use PhpMiddleware\RequestId;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Logger\LoggerFactory;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
$common = [
'level' => Level::Info->value,
'processors' => [RequestId\MonologProcessor::class],
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
];
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
return (static function (): array {
$common = [
'level' => Level::Info->value,
'processors' => [RequestId\MonologProcessor::class],
'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%',
];
'logger' => [
'Shlink' => [
'type' => LoggerType::FILE->value,
...$common,
],
'Access' => [
'type' => LoggerType::STREAM->value,
...$common,
],
],
return [
'dependencies' => [
'factories' => [
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
'Logger_Access' => [LoggerFactory::class, 'Access'],
],
'aliases' => [
'logger' => 'Logger_Shlink',
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
],
],
'mezzio-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger-name' => 'Logger_Access',
'format' => '%u "%r" %>s %B',
'logger' => [
'Shlink' => [
'type' => LoggerType::FILE->value,
...$common,
],
'Access' => [
'type' => LoggerType::STREAM->value,
'destination' => 'php://stderr',
'add_new_line' => ! runningInRoadRunner(),
...$common,
],
],
],
];
'dependencies' => [
'factories' => [
'Logger_Shlink' => [LoggerFactory::class, 'Shlink'],
'Logger_Access' => [LoggerFactory::class, 'Access'],
NullLogger::class => InvokableFactory::class,
],
'aliases' => [
'logger' => 'Logger_Shlink',
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
AccessLogMiddleware::LOGGER_SERVICE_NAME => 'Logger_Access',
],
],
'mezzio-swoole' => [
'swoole-http-server' => [
'logger' => [
// Let's disable mezio-swoole access logging, so that we can provide our own implementation,
// consistent for roadrunner and openswoole
'logger-name' => NullLogger::class,
],
],
],
];
})();

View File

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

View File

@@ -9,6 +9,7 @@ use Mezzio\ProblemDetails;
use Mezzio\Router;
use PhpMiddleware\RequestId\RequestIdMiddleware;
use RKA\Middleware\IpAddress;
use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware;
use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
return [
@@ -16,6 +17,7 @@ return [
'middleware_pipeline' => [
'error-handler' => [
'middleware' => [
AccessLogMiddleware::class,
ContentLengthMiddleware::class,
RequestIdMiddleware::class,
ErrorHandler::class,

View File

@@ -38,6 +38,7 @@ return (static function (): array {
Action\Visit\DomainVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
Action\Visit\OrphanVisitsAction::getRouteDef(),
Action\Visit\DeleteOrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
// Short URLs
@@ -53,6 +54,7 @@ return (static function (): array {
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),

View File

@@ -42,10 +42,9 @@ return (new ConfigAggregator\ConfigAggregator([
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
$isTestEnv
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider($isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php'),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
], 'data/cache/app_config.php', [

View File

@@ -13,10 +13,10 @@ const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated.
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
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_MARGIN = 0;
const DEFAULT_QR_CODE_FORMAT = 'png';
const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l';
const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true;
const MIN_TASK_WORKERS = 4;
const MIGRATIONS_TABLE = 'migrations';

View File

@@ -12,6 +12,16 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// Workaround to make this compatible with both openswoole 22 and earlier versions.
if (! function_exists('swoole_set_process_name')) {
// phpcs:disable
function swoole_set_process_name(string $name): void
{
OpenSwoole\Util::setProcessName($name);
}
// phpcs:enable
}
// This is one of the first files loaded. Configure the timezone here
date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get()));
@@ -21,7 +31,6 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) {
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
}
// Build container
return (static function (): ServiceManager {
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['dependencies']);

View File

@@ -1,4 +1,4 @@
version: '2.7'
version: '3.0'
rpc:
listen: tcp://127.0.0.1:6001
@@ -14,10 +14,12 @@ http:
forbid: ['.php', '.htaccess']
pool:
num_workers: 1
debug: true
jobs:
pool:
num_workers: 1
debug: true
timeout: 300
consume: ['shlink']
pipelines:
@@ -31,19 +33,8 @@ logs:
mode: development
channels:
http:
level: debug
mode: 'off' # Disable logging as Shlink handles it internally
server:
level: debug
metrics:
level: debug
reload:
interval: 1s
patterns: ['.php']
services:
http:
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
recursive: true
jobs:
dirs: ['../../bin', '../../config', '../../data/migrations', '../../module', '../../vendor']
recursive: true

View File

@@ -1,4 +1,4 @@
version: '2.7'
version: '3.0'
rpc:
listen: tcp://127.0.0.1:6001
@@ -7,18 +7,18 @@ server:
command: 'php -dopcache.enable_cli=1 -dopcache.validate_timestamps=0 ../../bin/roadrunner-worker.php'
http:
address: '0.0.0.0:${PORT}'
address: '0.0.0.0:${PORT:-8080}'
middleware: ['static']
static:
dir: '../../public'
forbid: ['.php', '.htaccess']
pool:
num_workers: ${WEB_WORKER_NUM}
num_workers: ${WEB_WORKER_NUM:-0}
jobs:
timeout: 300 # 5 minutes
pool:
num_workers: ${TASK_WORKER_NUM}
num_workers: ${TASK_WORKER_NUM:-0}
consume: ['shlink']
pipelines:
shlink:
@@ -31,6 +31,6 @@ logs:
mode: production
channels:
http:
level: info # Log all http requests, set to info to disable
mode: 'off' # Disable logging as Shlink handles it internally
server:
level: debug # Everything written to worker stderr is logged

View File

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

View File

@@ -121,6 +121,7 @@ $buildTestLoggerConfig = static fn (string $filename) => [
'level' => Level::Debug->value,
'type' => LoggerType::STREAM->value,
'destination' => sprintf('data/log/api-tests/%s', $filename),
'add_new_line' => true,
];
return [

View File

@@ -3,7 +3,7 @@
set -ex
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
ACCEPT_EULA=Y apt-get install msodbcsql17
apt-get install unixodbc-dev
ACCEPT_EULA=Y apt-get install msodbcsql18
# apt-get install unixodbc-dev

View File

@@ -71,6 +71,6 @@ CMD \
# Install dependencies if the vendor dir does not exist
if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \
# Download roadrunner binary
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --location bin/ && chmod +x bin/rr ; fi && \
if [[ ! -f "./bin/rr" ]]; then ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; fi && \
# This forces the app to be started every second until the exit code is 0
until ./bin/rr serve -c config/roadrunner/.rr.dev.yml; do sleep 1 ; done

View File

@@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 4.12.1
ENV OPENSWOOLE_VERSION 22.0.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

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
zend.assertions=1
assert.exception=1
memory_limit=256M
memory_limit=512M

View File

@@ -6,14 +6,12 @@ namespace Shlinkio\Shlink;
use Shlinkio\Shlink\Common\Logger\LoggerType;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
return [
'logger' => [
'Shlink' => [
'type' => LoggerType::STREAM->value,
'destination' => runningInRoadRunner() ? 'php://stderr' : 'php://stdout',
'destination' => 'php://stderr',
],
],

View File

@@ -1,44 +1,30 @@
#!/usr/bin/env sh
set -e
# If SHELL_VERBOSITY was not explicitly provided, run commands in quite mode (-q)
[ $SHELL_VERBOSITY ] && flags="" || flags="-q"
cd /etc/shlink
echo "Creating fresh database if needed..."
php bin/cli db:create -n ${flags}
flags="--clear-db-cache"
echo "Updating database..."
php bin/cli db:migrate -n ${flags}
echo "Generating proxies..."
php bin/doctrine orm:generate-proxies -n ${flags}
echo "Clearing entities cache..."
php bin/doctrine orm:clear-cache:metadata -n ${flags}
# Try to download GeoLite2 db file only if the license key env var was defined and skipping was not explicitly set
if [ ! -z "${GEOLITE_LICENSE_KEY}" ] && [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" != "true" ]; then
echo "Downloading GeoLite2 db file..."
php bin/cli visit:download-db -n ${flags}
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "true" ]; then
flags="${flags} --skip-download-geolite"
fi
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ]; then
# If INITIAL_API_KEY was provided, create an initial API key
if [ -n "${INITIAL_API_KEY}" ]; then
flags="${flags} --initial-api-key=${INITIAL_API_KEY}"
fi
php vendor/bin/shlink-installer init ${flags}
# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root
# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0
if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then
echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
# RoadRunner config needs these to have been set, so falling back to default values if not set yet
if [ "$SHLINK_RUNTIME" == 'rr' ]; then
export PORT="${PORT:-"8080"}"
# Default to 0 so that RoadRunner decides the number of workers based on the amount of logical CPUs
export WEB_WORKER_NUM="${WEB_WORKER_NUM:-"0"}"
export TASK_WORKER_NUM="${TASK_WORKER_NUM:-"0"}"
fi
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0

View File

@@ -0,0 +1,9 @@
{
"name": "shortCode",
"in": "path",
"description": "The short code for the short URL.",
"required": true,
"schema": {
"type": "string"
}
}

View File

@@ -11,13 +11,7 @@
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
@@ -127,13 +121,7 @@
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
@@ -295,13 +283,7 @@
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
"description": "The short code to edit.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"

View File

@@ -11,13 +11,7 @@
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
"description": "The short code for the short URL from which we want to get the visits.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
@@ -172,5 +166,79 @@
}
}
}
},
"delete": {
"operationId": "deleteShortUrlVisits",
"tags": [
"Visits"
],
"summary": "Delete visits for short URL",
"description": "Delete all existing visits on the short URL behind provided short code.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "Deleted visits",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deletedVisits": {
"description": "Amount of affected visits",
"type": "number"
}
}
},
"example": {
"deletedVisits": 536
}
}
}
},
"404": {
"description": "The short code does not belong to any short URL.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
},
"examples": {
"Short URL not found with API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -148,5 +148,55 @@
}
}
}
},
"delete": {
"operationId": "deleteOrphanVisits",
"tags": [
"Visits"
],
"summary": "Delete orphan visits",
"description": "Delete all visits to invalid short URLs, the base URL or any other 404.",
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "Deleted visits",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deletedVisits": {
"description": "Amount of affected visits",
"type": "number"
}
}
},
"example": {
"deletedVisits": 536
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -8,13 +8,7 @@
"description": "Represents a short URL. Tracks the visit and redirects tio the corresponding long URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
}
],
"responses": {

View File

@@ -8,13 +8,7 @@
"description": "Generates a QR code image pointing to a short URL.<br />Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
},
{
"name": "size",

View File

@@ -8,13 +8,7 @@
"description": "Generates a 1px transparent image which can be used to track emails with a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
"$ref": "../parameters/shortCode.json"
}
],
"responses": {

View File

@@ -2,7 +2,7 @@
# Run docker containers if they are not up yet
if ! [[ $(docker ps | grep shlink_swoole) ]]; then
docker-compose up -d
docker compose up -d
fi
docker exec -it shlink_swoole /bin/sh -c "$*"

View File

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

View File

@@ -13,15 +13,18 @@ return [
Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class,
Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class,
Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class,
Command\ShortUrl\DeleteShortUrlVisitsCommand::NAME => Command\ShortUrl\DeleteShortUrlVisitsCommand::class,
Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class,
Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class,
Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class,
Command\Visit\DeleteOrphanVisitsCommand::NAME => Command\Visit\DeleteOrphanVisitsCommand::class,
Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class,
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI;
use Doctrine\DBAL\Connection;
use GeoIp2\Database\Reader;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
@@ -43,15 +42,18 @@ return [
Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class,
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class,
Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\DeleteOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class,
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
@@ -89,6 +91,7 @@ return [
],
Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\ShortUrl\DeleteShortUrlCommand::class => [ShortUrl\DeleteShortUrlService::class],
Command\ShortUrl\DeleteShortUrlVisitsCommand::class => [ShortUrl\ShortUrlVisitsDeleter::class],
Command\Visit\DownloadGeoLiteDbCommand::class => [GeoLite\GeolocationDbUpdater::class],
Command\Visit\LocateVisitsCommand::class => [
@@ -97,11 +100,13 @@ return [
LockFactory::class,
],
Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class],
Command\Visit\DeleteOrphanVisitsCommand::class => [Visit\VisitsDeleter::class],
Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
@@ -116,7 +121,7 @@ return [
LockFactory::class,
Util\ProcessRunner::class,
PhpExecutableFinder::class,
Connection::class,
'em',
NoDbNameConnectionFactory::SERVICE_NAME,
],
Command\Db\MigrateDatabaseCommand::class => [

View File

@@ -14,24 +14,23 @@ use function is_string;
class RoleResolver implements RoleResolverInterface
{
public function __construct(private DomainServiceInterface $domainService, private string $defaultDomain)
{
public function __construct(
private readonly DomainServiceInterface $domainService,
private readonly string $defaultDomain,
) {
}
public function determineRoles(InputInterface $input): array
public function determineRoles(InputInterface $input): iterable
{
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
$roleDefinitions = [];
if ($author) {
$roleDefinitions[] = RoleDefinition::forAuthoredShortUrls();
yield RoleDefinition::forAuthoredShortUrls();
}
if (is_string($domainAuthority)) {
$roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority);
yield $this->resolveRoleForAuthority($domainAuthority);
}
return $roleDefinitions;
}
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition

View File

@@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface;
interface RoleResolverInterface
{
/**
* @return RoleDefinition[]
* @return iterable<RoleDefinition>
*/
public function determineRoles(InputInterface $input): array;
public function determineRoles(InputInterface $input): iterable;
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -39,10 +39,10 @@ class DisableKeyCommand extends Command
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
}
}

View File

@@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -24,8 +26,8 @@ class GenerateKeyCommand extends Command
public const NAME = 'api-key:generate';
public function __construct(
private ApiKeyServiceInterface $apiKeyService,
private RoleResolverInterface $roleResolver,
private readonly ApiKeyServiceInterface $apiKeyService,
private readonly RoleResolverInterface $roleResolver,
) {
parent::__construct();
}
@@ -56,7 +58,7 @@ class GenerateKeyCommand extends Command
$this
->setName(self::NAME)
->setDescription('Generates a new valid API key.')
->setDescription('Generate a new valid API key.')
->addOption(
'name',
'm',
@@ -90,16 +92,17 @@ class GenerateKeyCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$expirationDate = $input->getOption('expiration-date');
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
$input->getOption('name'),
...$this->roleResolver->determineRoles($input),
);
$apiKey = $this->apiKeyService->create(ApiKeyMeta::fromParams(
name: $input->getOption('name'),
expirationDate: isset($expirationDate) ? Chronos::parse($expirationDate) : null,
roleDefinitions: $this->roleResolver->determineRoles($input),
));
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Generated API key: "%s"', $apiKey->toString()));
if (! $apiKey->isAdmin()) {
if (! ApiKey::isAdmin($apiKey)) {
ShlinkTable::default($io)->render(
['Role name', 'Role metadata'],
$apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]),
@@ -108,6 +111,6 @@ class GenerateKeyCommand extends Command
);
}
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class InitialApiKeyCommand extends Command
{
public const NAME = 'api-key:initial';
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setHidden()
->setName(self::NAME)
->setDescription('Tries to create initial API key')
->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$key = $input->getArgument('apiKey');
$result = $this->apiKeyService->createInitial($key);
if ($result === null && $output->isVerbose()) {
$output->writeln('<comment>Other API keys already exist. Initial API key creation skipped.</comment>');
}
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -59,7 +59,7 @@ class ListKeysCommand extends Command
$rowData[] = sprintf($messagePattern, $this->getEnabledSymbol($apiKey));
}
$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) =>
empty($meta)
? $role->toFriendlyName()
@@ -77,7 +77,7 @@ class ListKeysCommand extends Command
'Roles',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function determineMessagePattern(ApiKey $apiKey): string

View File

@@ -5,22 +5,25 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\SqlitePlatform;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Process\PhpExecutableFinder;
use Throwable;
use function Functional\contains;
use function Functional\filter;
use const Shlinkio\Shlink\MIGRATIONS_TABLE;
use function Functional\map;
use function Functional\some;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
private readonly Connection $regularConn;
public const NAME = 'db:create';
public const DOCTRINE_SCRIPT = 'bin/doctrine';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
@@ -29,9 +32,10 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
LockFactory $locker,
ProcessRunnerInterface $processRunner,
PhpExecutableFinder $phpFinder,
private Connection $regularConn,
private Connection $noDbNameConn,
private readonly EntityManagerInterface $em,
private readonly Connection $noDbNameConn,
) {
$this->regularConn = $this->em->getConnection();
parent::__construct($locker, $processRunner, $phpFinder);
}
@@ -49,11 +53,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
{
$io = new SymfonyStyle($input, $output);
$this->checkDbExists();
if ($this->schemaExists()) {
if ($this->databaseTablesExist()) {
$io->success('Database already exists. Run "db:migrate" command to make sure it is up to date.');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
// Create database
@@ -61,32 +63,34 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
$io->success('Database properly created!');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function checkDbExists(): void
private function databaseTablesExist(): bool
{
if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) {
return;
}
$existingTables = $this->ensureDatabaseExistsAndGetTables();
$allMetadata = $this->em->getMetadataFactory()->getAllMetadata();
$shlinkTables = map($allMetadata, static fn (ClassMetadata $metadata) => $metadata->getTableName());
// In order to create the new database, we have to use a connection where the dbname was not set.
// Otherwise, it will fail to connect and will not be able to create the new database
$schemaManager = $this->noDbNameConn->createSchemaManager();
$databases = $schemaManager->listDatabases();
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null;
if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) {
$schemaManager->createDatabase($shlinkDatabase);
}
}
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();
return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE));
// Any other inconsistency will be taken care of by the migrations.
return some($shlinkTables, static fn (string $shlinkTable) => contains($existingTables, $shlinkTable));
}
private function ensureDatabaseExistsAndGetTables(): array
{
try {
// Trying to list tables requires opening a connection to configured database.
// If it fails, it means it does not exist yet.
return $this->regularConn->createSchemaManager()->listTableNames();
} catch (Throwable) {
// We cannot use getDatabase() to get the database name here, because then the driver will try to connect.
// Instead, we read from the raw params.
$shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? '';
// Create the database using a connection where the dbname was not set.
$this->noDbNameConn->createSchemaManager()->createDatabase($shlinkDatabase);
return [];
}
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -31,6 +31,6 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
$io->success('Database properly migrated!');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
@@ -109,6 +109,6 @@ class DomainRedirectsCommand extends Command
$io->success(sprintf('"Not found" redirects properly set for "%s"', $domainAuthority));
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
@@ -59,7 +59,7 @@ class ListDomainsCommand extends Command
}),
);
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function notFoundRedirectsToString(NotFoundRedirectConfigInterface $config): string

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
@@ -31,7 +31,6 @@ class CreateShortUrlCommand extends Command
public const NAME = 'short-url:create';
private ?SymfonyStyle $io;
private string $defaultDomain;
public function __construct(
private readonly UrlShortenerInterface $urlShortener,
@@ -39,7 +38,6 @@ class CreateShortUrlCommand extends Command
private readonly UrlShortenerOptions $options,
) {
parent::__construct();
$this->defaultDomain = $this->options->domain['hostname'] ?? '';
}
protected function configure(): void
@@ -121,7 +119,6 @@ class CreateShortUrlCommand extends Command
protected function interact(InputInterface $input, OutputInterface $output): void
{
$this->verifyLongUrlArgument($input, $output);
$this->verifyDomainArgument($input);
}
private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
@@ -138,19 +135,13 @@ class CreateShortUrlCommand extends Command
}
}
private function verifyDomainArgument(InputInterface $input): void
{
$domain = $input->getOption('domain');
$input->setOption('domain', $domain === $this->defaultDomain ? null : $domain);
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = $this->getIO($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error('A URL was not provided!');
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
$explodeWithComma = curry(explode(...))(',');
@@ -161,7 +152,7 @@ class CreateShortUrlCommand extends Command
$doValidateUrl = $input->getOption('validate-url');
try {
$shortUrl = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
$result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
@@ -176,14 +167,19 @@ class CreateShortUrlCommand extends Command
ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'),
], $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([
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 ExitCode::EXIT_SUCCESS;
} catch (InvalidUrlException | NonUniqueSlugException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
@@ -55,10 +55,10 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $identifier, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (Exception\ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
return $this->retry($io, $identifier, $e->getMessage());
}
@@ -75,7 +75,7 @@ class DeleteShortUrlCommand extends Command
$io->warning('Short URL was not deleted.');
}
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
return $forceDelete ? ExitCode::EXIT_SUCCESS : ExitCode::EXIT_WARNING;
}
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
{
public const NAME = 'short-url:visits-delete';
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes visits from a short URL')
->addArgument(
'shortCode',
InputArgument::REQUIRED,
'The short code for the short URL which visits will be deleted',
)
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain if the short code does not belong to the default one',
);
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int
{
$identifier = ShortUrlIdentifier::fromCli($input);
try {
$result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException) {
$io->warning(sprintf('Short URL not found for "%s"', $identifier->__toString()));
return ExitCode::EXIT_WARNING;
}
}
protected function getWarningMessage(): string
{
return 'You are about to delete all visits for a short URL. This operation cannot be undone.';
}
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Option\EndDateOption;
use Shlinkio\Shlink\CLI\Option\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
@@ -102,6 +102,12 @@ class ListShortUrlsCommand extends Command
InputOption::VALUE_NONE,
'Whether to display the tags or not.',
)
->addOption(
'show-domain',
null,
InputOption::VALUE_NONE,
'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".',
)
->addOption(
'show-api-key',
'k',
@@ -167,7 +173,7 @@ class ListShortUrlsCommand extends Command
$io->newLine();
$io->success('Short URLs properly listed');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function renderPage(
@@ -217,6 +223,10 @@ class ListShortUrlsCommand extends Command
if ($input->getOption('show-tags')) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
}
if ($input->getOption('show-domain')) {
$columnsMap['Domain'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->getDomain()?->authority ?? 'DEFAULT';
}
if ($input->getOption('show-api-key')) {
$columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string =>
$shortUrl->authorApiKey()?->__toString() ?? '';

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
@@ -56,10 +56,10 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -41,11 +41,11 @@ class DeleteTagsCommand extends Command
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return ExitCodes::EXIT_WARNING;
return ExitCode::EXIT_WARNING;
}
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
@@ -34,7 +34,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function getTagsRows(): array

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
@@ -42,10 +42,10 @@ class RenameTagCommand extends Command
try {
$this->tagService->renameTag(TagRenaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (TagNotFoundException | TagConflictException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -28,7 +28,7 @@ abstract class AbstractLockedCommand extends Command
$output->writeln(
sprintf('<comment>Command "%s" is already in progress. Skipping.</comment>', $lockConfig->lockName),
);
return ExitCodes::EXIT_WARNING;
return ExitCode::EXIT_WARNING;
}
try {

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractDeleteVisitsCommand extends Command
{
final protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
if (! $this->confirm($io)) {
$io->info('Operation aborted');
return ExitCode::EXIT_SUCCESS;
}
return $this->doExecute($input, $io);
}
private function confirm(SymfonyStyle $io): bool
{
$io->warning($this->getWarningMessage());
return $io->confirm('<comment>Continue deleting visits?</comment>', false);
}
abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int;
abstract protected function getWarningMessage(): string;
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Option\EndDateOption;
use Shlinkio\Shlink\CLI\Option\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
@@ -43,7 +43,7 @@ abstract class AbstractVisitsListCommand extends Command
ShlinkTable::default($output)->render($headers, $rows);
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
}
private function resolveRowsAndHeaders(Paginator $paginator): array

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
{
public const NAME = 'visit:orphan-delete';
public function __construct(private readonly VisitsDeleterInterface $deleter)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes all orphan visits');
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int
{
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return ExitCode::EXIT_SUCCESS;
}
protected function getWarningMessage(): string
{
return 'You are about to delete all orphan visits. This operation cannot be undone.';
}
}

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
@@ -56,7 +56,7 @@ class DownloadGeoLiteDbCommand extends Command
$io->success('GeoLite2 db file properly downloaded.');
}
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
$olderDbExists = $e->olderDbExists();
@@ -72,7 +72,7 @@ class DownloadGeoLiteDbCommand extends Command
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCodes::EXIT_WARNING : ExitCodes::EXIT_FAILURE;
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
}
}
}

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -116,14 +116,14 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
}
$this->io->success('Finished locating visits');
return ExitCodes::EXIT_SUCCESS;
return ExitCode::EXIT_SUCCESS;
} catch (Throwable $e) {
$this->io->error($e->getMessage());
if ($this->io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $this->io);
}
return ExitCodes::EXIT_FAILURE;
return ExitCode::EXIT_FAILURE;
}
}
@@ -171,7 +171,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
$downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME);
$exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io);
if ($exitCode === ExitCodes::EXIT_FAILURE) {
if ($exitCode === ExitCode::EXIT_FAILURE) {
throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.');
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Option;
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
final class ExitCodes
final class ExitCode
{
public const EXIT_SUCCESS = 0;
public const EXIT_FAILURE = -1;

View File

@@ -23,8 +23,8 @@ class ProcessRunner implements ProcessRunnerInterface
public function __construct(private ProcessHelper $helper, ?callable $createProcess = null)
{
$this->createProcess = $createProcess !== null
? Closure::fromCallable($createProcess)
: static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
? $createProcess(...)
: static fn (array $cmd) => new Process($cmd, timeout: LockedCommandConfig::DEFAULT_TTL);
}
public function run(OutputInterface $output, array $cmd): void

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class CreateShortUrlTest extends CliTestCase
{
#[Test]
public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
{
$slug = 'testing-default-domain';
$defaultDomain = 's.test';
[$output, $exitCode] = $this->exec(
[CreateShortUrlCommand::NAME, 'https://example.com', '--domain', $defaultDomain, '--custom-slug', $slug],
);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertStringContainsString('Generated short URL: http://' . $defaultDomain . '/' . $slug, $output);
[$listOutput] = $this->exec([ListShortUrlsCommand::NAME, '--show-domain', '--search-term', $slug]);
self::assertStringContainsString('DEFAULT', $listOutput);
}
}

View File

@@ -4,18 +4,19 @@ declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class GenerateApiKeyTest extends CliTestCase
{
/** @test */
#[Test]
public function outputIsCorrect(): void
{
[$output, $exitCode] = $this->exec([GenerateKeyCommand::NAME]);
self::assertStringContainsString('[OK] Generated API key', $output);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $exitCode);
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Importer\Command\ImportCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
use function fclose;
use function fopen;
use function fwrite;
use function is_string;
use function sys_get_temp_dir;
use function tempnam;
use function unlink;
class ImportShortUrlsTest extends CliTestCase
{
/**
* @var false|string|null
* @todo Use native type once PHP 8.1 support is dropped
*/
private mixed $tempCsvFile = null;
protected function setUp(): void
{
$this->tempCsvFile = tempnam(sys_get_temp_dir(), 'shlink_csv');
if (! $this->tempCsvFile) {
return;
}
$handle = fopen($this->tempCsvFile, 'w+');
if (! $handle) {
$this->fail('It was not possible to open the temporary file to write CSV on it');
}
fwrite(
$handle,
<<<CSV
longURL;tags;domain;short code;Title
https://shlink.io;foo,baz;s.test;testing-default-domain-import-1;
https://example.com;foo;s.test;testing-default-domain-import-2;
CSV,
);
fclose($handle);
}
protected function tearDown(): void
{
if (is_string($this->tempCsvFile)) {
unlink($this->tempCsvFile);
}
}
#[Test]
public function defaultDomainIsIgnoredWhenExplicitlyProvided(): void
{
if (! $this->tempCsvFile) {
$this->fail('It was not possible to create a temporary CSV file');
}
[$output] = $this->exec([ImportCommand::NAME, 'csv'], [$this->tempCsvFile, ';']);
self::assertStringContainsString('https://shlink.io: Imported', $output);
self::assertStringContainsString('https://example.com: Imported', $output);
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-1'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
[$listOutput1] = $this->exec(
[ListShortUrlsCommand::NAME, '--show-domain', '--search-term', 'testing-default-domain-import-2'],
);
self::assertStringContainsString('DEFAULT', $listOutput1);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ShlinkioCliTest\Shlink\CLI\Command;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\CLI\Command\Api\InitialApiKeyCommand;
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
class InitialApiKeyTest extends CliTestCase
{
#[Test]
public function createsNoKeyWhenOtherApiKeysAlreadyExist(): void
{
[$output] = $this->exec([InitialApiKeyCommand::NAME, 'new_api_key', '-v']);
self::assertEquals(
<<<OUT
Other API keys already exist. Initial API key creation skipped.
OUT,
$output,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@@ -32,12 +34,11 @@ class GenerateKeyCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
#[Test]
public function noExpirationDateIsDefinedIfNotProvided(): void
{
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(),
$this->isNull(),
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === null && $meta->expirationDate === null),
)->willReturn(ApiKey::create());
$this->commandTester->execute([]);
@@ -46,12 +47,11 @@ class GenerateKeyCommandTest extends TestCase
self::assertStringContainsString('Generated API key: ', $output);
}
/** @test */
#[Test]
public function expirationDateIsDefinedIfProvided(): void
{
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isInstanceOf(Chronos::class),
$this->isNull(),
$this->callback(fn (ApiKeyMeta $meta) => $meta->expirationDate instanceof Chronos),
)->willReturn(ApiKey::create());
$this->commandTester->execute([
@@ -59,12 +59,11 @@ class GenerateKeyCommandTest extends TestCase
]);
}
/** @test */
#[Test]
public function nameIsDefinedIfProvided(): void
{
$this->apiKeyService->expects($this->once())->method('create')->with(
$this->isNull(),
$this->isType('string'),
$this->callback(fn (ApiKeyMeta $meta) => $meta->name === 'Alice'),
)->willReturn(ApiKey::create());
$this->commandTester->execute([

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\InitialApiKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class InitialApiKeyCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
public function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new InitialApiKeyCommand($this->apiKeyService));
}
#[Test, DataProvider('provideParams')]
public function initialKeyIsCreatedWithProvidedValue(?ApiKey $result, bool $verbose, string $expectedOutput): void
{
$this->apiKeyService->expects($this->once())->method('createInitial')->with('the_key')->willReturn($result);
$this->commandTester->execute(
['apiKey' => 'the_key'],
['verbosity' => $verbose ? OutputInterface::VERBOSITY_VERBOSE : OutputInterface::VERBOSITY_NORMAL],
);
$output = $this->commandTester->getDisplay();
self::assertEquals($expectedOutput, $output);
}
public static function provideParams(): iterable
{
yield 'api key created, no verbose' => [ApiKey::create(), false, ''];
yield 'api key created, verbose' => [ApiKey::create(), true, ''];
yield 'no api key created, no verbose' => [null, false, ''];
yield 'no api key created, verbose' => [null, true, <<<OUT
Other API keys already exist. Initial API key creation skipped.
OUT,
];
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
@@ -29,10 +31,7 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService));
}
/**
* @test
* @dataProvider provideKeysAndOutputs
*/
#[Test, DataProvider('provideKeysAndOutputs')]
public function returnsExpectedOutput(array $keys, bool $enabledOnly, string $expected): void
{
$this->apiKeyService->expects($this->once())->method('listKeys')->with($enabledOnly)->willReturn($keys);
@@ -43,14 +42,14 @@ class ListKeysCommandTest extends TestCase
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');
yield 'all keys' => [
[
$apiKey1 = ApiKey::create()->disable(),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($dateInThePast)),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: $dateInThePast)),
$apiKey3 = ApiKey::create(),
],
false,
@@ -84,14 +83,14 @@ class ListKeysCommandTest extends TestCase
yield 'with roles' => [
[
$apiKey1 = ApiKey::create(),
$apiKey2 = $this->apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
$apiKey3 = $this->apiKeyWithRoles(
[RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com')))],
$apiKey2 = self::apiKeyWithRoles([RoleDefinition::forAuthoredShortUrls()]),
$apiKey3 = self::apiKeyWithRoles(
[RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com')))],
),
$apiKey4 = ApiKey::create(),
$apiKey5 = $this->apiKeyWithRoles([
$apiKey5 = self::apiKeyWithRoles([
RoleDefinition::forAuthoredShortUrls(),
RoleDefinition::forDomain($this->domainWithId(Domain::withAuthority('example.com'))),
RoleDefinition::forDomain(self::domainWithId(Domain::withAuthority('example.com'))),
]),
$apiKey6 = ApiKey::create(),
],
@@ -118,9 +117,9 @@ class ListKeysCommandTest extends TestCase
];
yield 'with names' => [
[
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice')),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::withName('Alice and Bob')),
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::withName('')),
$apiKey1 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice')),
$apiKey2 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: 'Alice and Bob')),
$apiKey3 = ApiKey::fromMeta(ApiKeyMeta::fromParams(name: '')),
$apiKey4 = ApiKey::create(),
],
true,
@@ -141,7 +140,7 @@ class ListKeysCommandTest extends TestCase
];
}
private function apiKeyWithRoles(array $roles): ApiKey
private static function apiKeyWithRoles(array $roles): ApiKey
{
$apiKey = ApiKey::create();
foreach ($roles as $role) {
@@ -151,7 +150,7 @@ class ListKeysCommandTest extends TestCase
return $apiKey;
}
private function domainWithId(Domain $domain): Domain
private static function domainWithId(Domain $domain): Domain
{
$domain->setId('1');
return $domain;

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,27 +4,30 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Laminas\ServiceManager\Exception\ServiceNotFoundException;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class CreateShortUrlCommandTest extends TestCase
{
use CliTestUtilsTrait;
private const DEFAULT_DOMAIN = 'default.com';
private CommandTester $commandTester;
private MockObject & UrlShortenerInterface $urlShortener;
private MockObject & ShortUrlStringifierInterface $stringifier;
@@ -38,18 +41,20 @@ class CreateShortUrlCommandTest extends TestCase
$this->urlShortener,
$this->stringifier,
new UrlShortenerOptions(
domain: ['hostname' => self::DEFAULT_DOMAIN, 'schema' => ''],
domain: ['hostname' => 'example.com', 'schema' => ''],
defaultShortCodesLength: 5,
),
);
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
#[Test]
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{
$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(
'stringified_short_url',
);
@@ -57,14 +62,15 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
'--max-visits' => '3',
]);
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
self::assertStringNotContainsString('but the real-time updates cannot', $output);
}
/** @test */
#[Test]
public function exceptionWhileParsingLongUrlOutputsError(): void
{
$url = 'http://domain.com/invalid';
@@ -76,11 +82,11 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
}
/** @test */
#[Test]
public function providingNonUniqueSlugOutputsError(): void
{
$this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException(
@@ -91,11 +97,11 @@ class CreateShortUrlCommandTest extends TestCase
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
}
/** @test */
#[Test]
public function properlyProcessesProvidedTags(): void
{
$shortUrl = ShortUrl::createFake();
@@ -104,7 +110,7 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $creation->tags);
return true;
}),
)->willReturn($shortUrl);
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->expects($this->once())->method('stringify')->with($shortUrl)->willReturn(
'stringified_short_url',
);
@@ -115,14 +121,11 @@ class CreateShortUrlCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString('stringified_short_url', $output);
}
/**
* @test
* @dataProvider provideDomains
*/
#[Test, DataProvider('provideDomains')]
public function properlyProcessesProvidedDomain(array $input, ?string $expectedDomain): void
{
$this->urlShortener->expects($this->once())->method('shorten')->with(
@@ -130,27 +133,23 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals($expectedDomain, $meta->domain);
return true;
}),
)->willReturn(ShortUrl::createFake());
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertEquals(ExitCode::EXIT_SUCCESS, $this->commandTester->getStatusCode());
}
public function provideDomains(): iterable
public static function provideDomains(): iterable
{
yield 'no domain' => [[], null];
yield 'non-default domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
yield 'non-default domain bar' => [['-d' => 'bar.com'], 'bar.com'];
yield 'default domain' => [['--domain' => self::DEFAULT_DOMAIN], null];
yield 'domain foo' => [['--domain' => 'foo.com'], 'foo.com'];
yield 'domain bar' => [['-d' => 'bar.com'], 'bar.com'];
}
/**
* @test
* @dataProvider provideFlags
*/
#[Test, DataProvider('provideFlags')]
public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
{
$shortUrl = ShortUrl::createFake();
@@ -159,16 +158,52 @@ class CreateShortUrlCommandTest extends TestCase
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
return true;
}),
)->willReturn($shortUrl);
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);
}
public function provideFlags(): iterable
public static function provideFlags(): iterable
{
yield 'no flags' => [[], null];
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;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
@@ -30,7 +32,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service));
}
/** @test */
#[Test]
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
@@ -48,7 +50,7 @@ class DeleteShortUrlCommandTest extends TestCase
);
}
/** @test */
#[Test]
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
@@ -64,10 +66,7 @@ class DeleteShortUrlCommandTest extends TestCase
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}
/**
* @test
* @dataProvider provideRetryDeleteAnswers
*/
#[Test, DataProvider('provideRetryDeleteAnswers')]
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
int $expectedDeleteCalls,
@@ -98,14 +97,14 @@ class DeleteShortUrlCommandTest extends TestCase
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 no to retry' => [['no'], 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
{
$shortCode = 'abc123';

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteShortUrlVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ShortUrlVisitsDeleterInterface $deleter;
protected function setUp(): void
{
$this->deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
}
#[Test, DataProvider('provideCancellingInputs')]
public function executionIsAbortedIfManuallyCancelled(array $input): void
{
$this->deleter->expects($this->never())->method('deleteShortUrlVisits');
$this->commandTester->setInputs($input);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertStringContainsString('Operation aborted', $output);
}
public static function provideCancellingInputs(): iterable
{
yield 'default input' => [[]];
yield 'no' => [['no']];
yield 'n' => [['n']];
}
#[Test, DataProvider('provideErrorArgs')]
public function warningIsPrintedInCaseOfNotFoundShortUrl(array $args, string $expectedError): void
{
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willThrowException(
new ShortUrlNotFoundException(),
);
$this->commandTester->setInputs(['yes']);
$exitCode = $this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_WARNING, $exitCode);
self::assertStringContainsString($expectedError, $output);
}
public static function provideErrorArgs(): iterable
{
yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"'];
yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
}
#[Test]
public function successMessageIsPrintedForValidShortUrls(): void
{
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5));
$this->commandTester->setInputs(['yes']);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
self::assertStringContainsString('Successfully deleted 5 visits', $output);
}
}

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand;
@@ -39,7 +40,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
#[Test]
public function noDateFlagsTriesToListWithoutDateRange(): void
{
$shortCode = 'abc123';
@@ -51,7 +52,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
}
/** @test */
#[Test]
public function providingDateFlagsTheListGetsFiltered(): void
{
$shortCode = 'abc123';
@@ -69,7 +70,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
]);
}
/** @test */
#[Test]
public function providingInvalidDatesPrintsWarning(): void
{
$shortCode = 'abc123';
@@ -91,7 +92,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
);
}
/** @test */
#[Test]
public function outputIsProperlyGenerated(): void
{
$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 Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
@@ -41,13 +43,13 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester = $this->testerForCommand($command);
}
/** @test */
#[Test]
public function loadingMorePagesCallsListMoreTimes(): void
{
// The paginator will return more than one page
$data = [];
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()
@@ -63,13 +65,13 @@ class ListShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Continue with page 5?', $output);
}
/** @test */
#[Test]
public function havingMorePagesButAnsweringNoCallsListJustOnce(): void
{
// The paginator will return more than one page
$data = [];
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(
@@ -89,7 +91,7 @@ class ListShortUrlsCommandTest extends TestCase
self::assertStringNotContainsString('Continue with page 3?', $output);
}
/** @test */
#[Test]
public function passingPageWillMakeListStartOnThatPage(): void
{
$page = 5;
@@ -101,10 +103,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute(['--page' => $page]);
}
/**
* @test
* @dataProvider provideOptionalFlags
*/
#[Test, DataProvider('provideOptionalFlags')]
public function provideOptionalFlagsMakesNewColumnsToBeIncluded(
array $input,
array $expectedContents,
@@ -115,7 +114,7 @@ class ListShortUrlsCommandTest extends TestCase
ShortUrlsParams::emptyInstance(),
)->willReturn(new Paginator(new ArrayAdapter([
ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'foo.com',
'longUrl' => 'https://foo.com',
'tags' => ['foo', 'bar', 'baz'],
'apiKey' => $apiKey,
])),
@@ -137,21 +136,27 @@ 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::fromParams(name: 'my api key'));
$key = $apiKey->toString();
yield 'tags only' => [
['--show-tags' => true],
['| Tags ', '| foo, bar, baz'],
['| API Key ', '| API Key Name |', $key, '| my api key'],
['| API Key ', '| API Key Name |', $key, '| my api key', '| Domain', '| DEFAULT'],
$apiKey,
];
yield 'domain only' => [
['--show-domain' => true],
['| Domain', '| DEFAULT'],
['| Tags ', '| foo, bar, baz', '| API Key ', '| API Key Name |', $key, '| my api key'],
$apiKey,
];
yield 'api key only' => [
['--show-api-key' => true],
['| API Key ', $key],
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key'],
['| Tags ', '| foo, bar, baz', '| API Key Name |', '| my api key', '| Domain', '| DEFAULT'],
$apiKey,
];
yield 'api key name only' => [
@@ -166,18 +171,30 @@ class ListShortUrlsCommandTest extends TestCase
['| API Key Name |', '| my api key'],
$apiKey,
];
yield 'tags and domain' => [
['--show-tags' => true, '--show-domain' => true],
['| Tags ', '| foo, bar, baz', '| Domain', '| DEFAULT'],
['| API Key Name |', '| my api key'],
$apiKey,
];
yield 'all' => [
['--show-tags' => true, '--show-api-key' => true, '--show-api-key-name' => true],
['| API Key ', '| Tags ', '| API Key Name |', '| foo, bar, baz', $key, '| my api key'],
['--show-tags' => true, '--show-domain' => true, '--show-api-key' => true, '--show-api-key-name' => true],
[
'| API Key ',
'| Tags ',
'| API Key Name |',
'| foo, bar, baz',
$key,
'| my api key',
'| Domain',
'| DEFAULT',
],
[],
$apiKey,
];
}
/**
* @test
* @dataProvider provideArgs
*/
#[Test, DataProvider('provideArgs')]
public function serviceIsInvokedWithProvidedArgs(
array $commandArgs,
?int $page,
@@ -200,7 +217,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute($commandArgs);
}
public function provideArgs(): iterable
public static function provideArgs(): iterable
{
yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
@@ -241,10 +258,7 @@ class ListShortUrlsCommandTest extends TestCase
];
}
/**
* @test
* @dataProvider provideOrderBy
*/
#[Test, DataProvider('provideOrderBy')]
public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
{
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([
@@ -255,7 +269,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute($commandArgs);
}
public function provideOrderBy(): iterable
public static function provideOrderBy(): iterable
{
yield [[], null];
yield [['--order-by' => 'visits'], 'visits'];
@@ -264,7 +278,7 @@ class ListShortUrlsCommandTest extends TestCase
yield [['--order-by' => 'title-DESC'], 'title-DESC'];
}
/** @test */
#[Test]
public function requestingAllElementsWillSetItemsPerPage(): void
{
$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;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
@@ -31,7 +32,7 @@ class ResolveUrlCommandTest extends TestCase
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver));
}
/** @test */
#[Test]
public function correctShortCodeResolvesUrl(): void
{
$shortCode = 'abc123';
@@ -46,7 +47,7 @@ class ResolveUrlCommandTest extends TestCase
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
/** @test */
#[Test]
public function incorrectShortCodeOutputsErrorMessage(): void
{
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123');

View File

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

View File

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

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