Compare commits

...

102 Commits

Author SHA1 Message Date
Alejandro Celaya
f563e777cc Merge pull request #369 from acelaya/feature/postgres-query-error
Feature/postgres query error
2019-03-05 14:26:36 +01:00
Alejandro Celaya
a63447b12b Updated changelog 2019-03-05 14:17:47 +01:00
Alejandro Celaya
0f81c3ab92 Fixed error when using postgres in a SELECT count query where a ORDER BY was added by mistake 2019-03-05 13:50:44 +01:00
Alejandro Celaya
425f254453 Added posgres container for development 2019-03-05 13:39:45 +01:00
Alejandro Celaya
a9d9ec5bf9 Merge pull request #365 from acelaya/feature/coding-styles
Feature/coding styles
2019-02-26 23:06:08 +01:00
Alejandro Celaya
0c5c752ffe Updated changelog 2019-02-26 22:58:03 +01:00
Alejandro Celaya
4b556cd79f Updated to shlinkio coding standard 1.1.0 2019-02-26 22:56:43 +01:00
Alejandro Celaya
3d32a90f8e Merge pull request #364 from acelaya/bugfix/non-locatable-addresses
Bugfix/non locatable addresses
2019-02-26 22:53:07 +01:00
Alejandro Celaya
0b4c334163 Fixed typo 2019-02-26 22:42:33 +01:00
Alejandro Celaya
312fc0984b Fixed mutation score by provideing more tests 2019-02-26 22:41:04 +01:00
Alejandro Celaya
30bf1c2641 Added tests for new cases with non-locatable addresses 2019-02-26 22:31:07 +01:00
Alejandro Celaya
2d1d7357a3 Given more semantic cases in which a visit cannot be located 2019-02-26 21:39:45 +01:00
Alejandro Celaya
c70077c525 Merge pull request #361 from acelaya/feature/paginated-visits
Feature/paginated visits
2019-02-23 10:09:46 +01:00
Alejandro Celaya
d2fad0128f Fixed bug missing unprocessed visits while iterating and updating, while drastically improving the performance 2019-02-23 09:58:02 +01:00
Alejandro Celaya
62133c994f Tagged v1.16 in changelog 2019-02-23 08:30:35 +01:00
Alejandro Celaya
091ea974eb Simplified implementation iterating unlocated visits 2019-02-23 07:29:07 +01:00
Alejandro Celaya
955ae00036 Updated changelog 2019-02-22 19:54:23 +01:00
Alejandro Celaya
7d4de590e5 Created ImplicitLoopPaginatorTest 2019-02-22 19:53:10 +01:00
Alejandro Celaya
292937b962 Updated VisitRepository::findUnlocatedVisits methods so that it paginates the amount of elements loaded in memory 2019-02-22 19:31:03 +01:00
Alejandro Celaya
08bd4f131c Merge pull request #359 from acelaya/feature/memory-leak
Feature/memory leak
2019-02-20 18:09:00 +01:00
Alejandro Celaya
38cc83a4ee Removed uneeded inline type hints 2019-02-17 20:32:18 +01:00
Alejandro Celaya
687a1cc9c7 Reduced amount of dead lines in tests 2019-02-17 20:28:34 +01:00
Alejandro Celaya
1bcd03b150 Renamed method 2019-02-17 13:21:07 +01:00
Alejandro Celaya
e2abe23895 Defined stricter model to represent one geo location 2019-02-17 13:01:21 +01:00
Alejandro Celaya
5c5dde48de Ensured install and update script change to the project dir 2019-02-17 10:51:22 +01:00
Alejandro Celaya
d9f11e190f Merge pull request #357 from acelaya/feature/phpstan0.11
Feature/phpstan0.11
2019-02-17 10:19:14 +01:00
Alejandro Celaya
1ab2d7a240 Increased scrutinizer timeout while waiting for code coverage, from 5 min to 10 min 2019-02-17 10:12:13 +01:00
Alejandro Celaya
580050cb7d Updated to phpstan 0.11 2019-02-17 10:06:34 +01:00
Alejandro Celaya
eab5659163 Added status codes returned by CLI commands 2019-02-16 23:21:40 +01:00
Alejandro Celaya
397b350cfc Merge pull request #356 from acelaya/feature/deprecated-commands
Deprecated commands to generate secret and charset
2019-02-16 23:20:55 +01:00
Alejandro Celaya
c0130c997a Deprecated commands to generate secret and charset 2019-02-16 22:53:49 +01:00
Alejandro Celaya
fd7f1b32dd Merge pull request #354 from acelaya/feature/infection
Feature/infection
2019-02-16 22:25:13 +01:00
Alejandro Celaya
0e286d8261 Temporarely downgrading phpstan 2019-02-16 22:17:01 +01:00
Alejandro Celaya
ce7d2d1fb0 Fixed coding styles 2019-02-16 22:04:11 +01:00
Alejandro Celaya
2175b8a7bb Improved tests to increase MSI to 70% 2019-02-16 21:58:14 +01:00
Alejandro Celaya
6c0893cdf8 Improved tests to increase MSI to 69% 2019-02-16 21:24:32 +01:00
Alejandro Celaya
25927a296d Merge pull request #353 from acelaya/feature/testing-tools
Updated testing tools
2019-02-16 20:50:22 +01:00
Alejandro Celaya
ee4db44fe8 Fixed phpcov dep not properly resolved on PHP 7.1 envs 2019-02-16 20:39:46 +01:00
Alejandro Celaya
b8cb38ae5c Updated testing tools 2019-02-16 10:53:45 +01:00
Alejandro Celaya
899bfdce2b Merge pull request #352 from acelaya/feature/configure-installer
Updated required shlink-installer version and added config for instal…
2019-02-10 22:05:03 +01:00
Alejandro Celaya
456960e1f0 Updated required shlink-installer version and added config for installer plugins 2019-02-10 21:57:29 +01:00
Alejandro Celaya
04e03e9b6e Merge pull request #348 from acelaya/feature/external-installer
Feature/external installer
2019-02-06 23:56:10 +01:00
Alejandro Celaya
a7283da016 Updated changelog 2019-02-06 23:31:37 +01:00
Alejandro Celaya
672321abab Removed class which is now part of the installer package 2019-02-04 20:16:29 +01:00
Alejandro Celaya
2059b4050b Removed installer and used external package instead 2019-02-04 20:14:59 +01:00
Alejandro Celaya
171b43c517 Fixed some configs 2019-02-04 19:43:21 +01:00
Alejandro Celaya
ccb7c8f8d9 Merge pull request #345 from acelaya/bugfix/charset-installation
Bugfix/charset installation
2019-02-03 13:19:23 +01:00
Alejandro Celaya
abbc66ac07 Unified config for installer tool 2019-02-03 13:12:17 +01:00
Alejandro Celaya
2d18ef5cee Updated installer so that it no longer asks for a charset and instead just generates one 2019-02-03 13:02:12 +01:00
Alejandro Celaya
79c132219b Merge pull request #343 from acelaya/feature/allow-check-duplicates
Feature/allow check duplicates
2019-02-03 12:22:22 +01:00
Alejandro Celaya
04d4d4a8d7 Updated GenerateShortUrlCommand to accept the findIfExists flag 2019-02-03 12:11:22 +01:00
Alejandro Celaya
a918113ba0 Documented new findIfExists flag 2019-02-03 11:24:26 +01:00
Alejandro Celaya
810b25ff14 Added API tests covering creating short URLs with new findIfExists param 2019-02-03 11:01:38 +01:00
Alejandro Celaya
c4fd8d5120 Implemented feature to optionally return an existing short url when all provided params match an existing one 2019-02-03 09:40:32 +01:00
Alejandro Celaya
772494f46f Moved process of sluggifying custom slug to a filter 2019-02-03 08:17:27 +01:00
Alejandro Celaya
594e7da256 Created new findIfExists meta param 2019-02-02 11:05:28 +01:00
Alejandro Celaya
49668547d7 Fixed typo 2019-02-02 11:05:28 +01:00
Alejandro Celaya
4c46aaead8 Improved API tests and added test for short URLs creation 2019-02-02 11:05:28 +01:00
Alejandro Celaya
d61f5faf59 Refactored UrlShortener public method to receibe DTOs instead of primitive params 2019-02-02 11:05:28 +01:00
Alejandro Celaya
5756609531 Deleted deprecated constant 2019-02-02 11:05:28 +01:00
Alejandro Celaya
ea1b285d52 Little refactopring on tests config file 2019-02-02 11:05:28 +01:00
Alejandro Celaya
bc61b55b94 Merge pull request #344 from acelaya/feature/update-dev-docker
Updated docker containers used in development
2019-02-02 11:04:39 +01:00
Alejandro Celaya
48f6a96da8 Updated docker containers used in development 2019-02-02 10:53:34 +01:00
Alejandro Celaya
967f1657d2 Merge pull request #340 from acelaya/bugfix/preview-error
Bugfix/preview error
2019-01-28 11:00:23 +01:00
Alejandro Celaya
f90a323374 Updated changelog 2019-01-28 10:53:24 +01:00
Alejandro Celaya
d289c62532 Fixed config file being deleted by mistake by build script 2019-01-28 10:52:05 +01:00
Alejandro Celaya
05695e8cd6 Merge pull request #339 from acelaya/feature/api-test
Feature/api test
2019-01-27 12:49:59 +01:00
Alejandro Celaya
d6a7a6ce66 Created new API test 2019-01-27 12:36:22 +01:00
Alejandro Celaya
05c7672de3 Improved API tests by adding fixtures 2019-01-27 12:14:22 +01:00
Alejandro Celaya
ce515767ce Updated changelog 2019-01-27 10:56:14 +01:00
Alejandro Celaya
76d8fd1023 Improved how API tests are executed 2019-01-27 10:54:04 +01:00
Alejandro Celaya
558e259b84 Minor refactorings 2019-01-27 10:30:38 +01:00
Alejandro Celaya
f467bed24c Used multiple commands with && instead of composer array for API tests command 2019-01-27 10:15:48 +01:00
Alejandro Celaya
fa753ad6fb Added api test to test:ci command 2019-01-26 11:04:50 +01:00
Alejandro Celaya
22d61fead7 Prepared configs for API tests 2019-01-26 10:19:20 +01:00
Alejandro Celaya
c4af1471f0 Simplified and united configs for tests 2019-01-26 09:42:08 +01:00
Alejandro Celaya
87ba7a7179 Updated structure for tests config files 2019-01-26 09:09:57 +01:00
Alejandro Celaya
e7c5cf0846 Merge pull request #337 from acelaya/feature/db-tests
Feature/db tests
2019-01-21 21:34:30 +01:00
Alejandro Celaya
1aaedb8d90 Udated changelog 2019-01-21 21:27:16 +01:00
Alejandro Celaya
284de28f76 Removed duplicated code to define testing database connection params 2019-01-20 22:08:32 +01:00
Alejandro Celaya
687d8d91a9 Changed references to functional tests by database tests 2019-01-20 21:49:07 +01:00
Alejandro Celaya
771087c6c6 Happy 2019! 2019-01-05 08:41:48 +01:00
Alejandro Celaya
1fd3e6365e Merge pull request #331 from acelaya/feature/health
Feature/health
2018-12-29 14:51:08 +01:00
Alejandro Celaya
28989296eb Updated changelog 2018-12-29 14:45:20 +01:00
Alejandro Celaya
fd8d73af38 Documented health endpoint 2018-12-29 14:39:31 +01:00
Alejandro Celaya
144a5415da Handled connection exceptions in Health action 2018-12-29 13:50:42 +01:00
Alejandro Celaya
d58e24bce5 Created health action related tests 2018-12-29 11:54:28 +01:00
Alejandro Celaya
0f86123ccb Finished health action implementation 2018-12-29 11:54:28 +01:00
Alejandro Celaya
3f65ef998c Created HealthAction 2018-12-29 11:54:28 +01:00
Alejandro Celaya
29d49dfbf4 Merge pull request #332 from acelaya/feature/php7.3
Do not allow failures on PHP 7.3 build
2018-12-29 11:53:44 +01:00
Alejandro Celaya
701d17f6f2 Do not allow failures on PHP 7.3 build 2018-12-29 11:43:28 +01:00
Alejandro Celaya
642431c43e Reverted to diactoros v2.0.1 while a bug is fixed 2018-12-29 11:13:23 +01:00
Alejandro Celaya
3c5b47784d Merge pull request #329 from PeterDaveHello/Update-Travis-CI-Config
Drop deprecated Travis CI container-based env config
2018-12-29 09:36:02 +01:00
Peter Dave Hello
64d7fe8bbf Drop deprecated Travis CI container-based env config
Ref: https://blog.travis-ci.com/2018-10-04-combining-linux-infrastructures
2018-12-29 16:20:18 +08:00
Alejandro Celaya
32070b1fa7 Do not use ServerRequestFactory::fromGlobals in tests 2018-12-25 23:19:36 +01:00
Alejandro Celaya
8b3324e143 Merge pull request #327 from PeterDaveHello/Add-Table-of-Contents
Add Table of Contents in README.md
2018-12-25 10:52:21 +01:00
Peter Dave Hello
f40a5a029c Add Table of Contents in README.md 2018-12-25 14:12:16 +08:00
Alejandro Celaya
eac82a602c Merge pull request #325 from acelaya/feature/dql
Feature/dql
2018-12-19 17:47:10 +01:00
Alejandro Celaya
d1312e0934 Added unreleased changes to changelog 2018-12-19 14:37:47 +01:00
Alejandro Celaya
58dbee10c5 Used DQL for non-dynamic query in VisitRepository 2018-12-19 14:36:03 +01:00
Alejandro Celaya
f8207994dc Removed superfluous method docs 2018-12-19 14:31:52 +01:00
Alejandro Celaya
2030401859 Migrated non-dynamic query to DQL in ShortUrlRepository 2018-12-19 14:29:43 +01:00
282 changed files with 3288 additions and 3914 deletions

10
.gitattributes vendored
View File

@@ -1,12 +1,14 @@
/config/test export-ignore
/data/infra export-ignore
/docs export-ignore
/module/CLI/test export-ignore
/module/CLI/test-resources export-ignore
/module/Common/test export-ignore
/module/Common/test-func export-ignore
/module/Common/test-db export-ignore
/module/Core/test export-ignore
/module/Core/test-func export-ignore
/module/Core/test-db export-ignore
/module/Rest/test export-ignore
/module/Rest/test-api export-ignore
.env.dist export-ignore
.gitattributes export-ignore
.gitignore export-ignore
@@ -17,9 +19,9 @@ build.sh export-ignore
CHANGELOG.md export-ignore
docker-compose.override.yml.dist export-ignore
docker-compose.yml export-ignore
func_tests_bootstrap.php export-ignore
indocker export-ignore
phpcs.xml export-ignore
phpunit.xml.dist export-ignore
phpunit-func.xml export-ignore
phpunit-api.xml export-ignore
phpunit-db.xml export-ignore
phpstan.neon

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ composer.phar
vendor/
.env
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.mmdb
docs/swagger-ui*
docker-compose.override.yml
.phpunit.result.cache

View File

@@ -2,7 +2,7 @@
namespace PHPSTORM_META;
use Psr\Container\ContainerInterface;
use Zend\ServiceManager\ServiceManager;
use Zend\ServiceManager\ServiceLocatorInterface;
/**
* PhpStorm Container Interop code completion
@@ -17,7 +17,7 @@ $STATIC_METHOD_TYPES = [
ContainerInterface::get('') => [
'' == '@',
],
ServiceManager::build('') => [
ServiceLocatorInterface::build('') => [
'' == '@',
],
];

View File

@@ -1,5 +1,6 @@
tools:
external_code_coverage: true
external_code_coverage:
timeout: 600
checks:
php:
code_rating: true

View File

@@ -1,7 +1,5 @@
language: php
sudo: false # Use containerized environment
branches:
only:
- /.*/
@@ -11,10 +9,6 @@ php:
- 7.2
- 7.3
matrix:
allow_failures:
- php: 7.3
before_install:
- echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
@@ -27,7 +21,7 @@ install:
script:
- mkdir build
- composer check
- composer ci
after_success:
- rm -f build/clover.xml

View File

@@ -2,7 +2,113 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.16.2 - 2019-03-05
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#368](https://github.com/shlinkio/shlink/issues/368) Fixed error produced when running a `SELECT COUNT(...)` with `ORDER BY` in PostgreSQL databases.
## 1.16.1 - 2019-02-26
#### Added
* *Nothing*
#### Changed
* [#363](https://github.com/shlinkio/shlink/issues/363) Updated to `shlinkio/php-coding-standard` version 1.1.0
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#362](https://github.com/shlinkio/shlink/issues/362) Fixed all visits without an IP address being processed every time the `visit:process` command is executed.
## 1.16.0 - 2019-02-23
#### Added
* [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures.
Call [GET /rest/health] in order to get a response like this:
```http
HTTP/1.1 200 OK
Content-Type: application/health+json
Content-Length: 681
{
"status": "pass",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
```
The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/).
* [#279](https://github.com/shlinkio/shlink/issues/279) Added new `findIfExists` flag to the `[POST /short-url]` REST endpoint and the `short-urls:generate` CLI command. It can be used to return existing short URLs when found, instead of creating new ones.
Thanks to this flag you won't need to remember if you created a short URL for a long one. It will just create it if needed or return the existing one if possible.
The behavior might be a little bit counterintuitive when combined with other params. This is how the endpoint behaves when providing this new flag:
* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.
* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.
* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.
* [#336](https://github.com/shlinkio/shlink/issues/336) Added an API test suite which performs API calls to an actual instance of the web service.
#### Changed
* [#342](https://github.com/shlinkio/shlink/issues/342) The installer no longer asks for a charset to be provided, and instead, it shuffles the base62 charset.
* [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated.
* [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build.
* [#335](https://github.com/shlinkio/shlink/issues/335) Renamed functional test suite to database test suite, since that better describes what it actually does.
* [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool.
* [#261](https://github.com/shlinkio/shlink/issues/261) Increased mutation score to 70%.
#### Deprecated
* [#351](https://github.com/shlinkio/shlink/issues/351) Deprecated `config:generate-charset` and `config:generate-secret` CLI commands.
#### Removed
* *Nothing*
#### Fixed
* [#317](https://github.com/shlinkio/shlink/issues/317) Fixed error while trying to generate previews because of global config file being deleted by mistake by build script.
* [#307](https://github.com/shlinkio/shlink/issues/307) Fixed memory leak while trying to process huge amounts of visits due to the query not being properly paginated.
## 1.15.1 - 2018-12-16

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018 Alejandro Celaya
Copyright (c) 2016-2019 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -9,6 +9,14 @@
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
## Table of Contents
- [Installation](#installation)
- [Update to new version](#update-to-new-version)
- [Using a docker image](#using-a-docker-image)
- [Using shlink](#using-shlink)
- [Shlink CLI Help](#shlink-cli-help)
## Installation
First make sure the host where you are going to run shlink fulfills these requirements:
@@ -258,8 +266,8 @@ Available commands:
api-key:generate Generates a new valid API key.
api-key:list Lists all the available API keys.
config
config:generate-charset Generates a character set sample just by shuffling the default one, "123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
config:generate-secret Generates a random secret string that can be used for JWT token encryption
config:generate-charset [DEPRECATED] Generates a character set sample just by shuffling the default one, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ". Then it can be set in the SHORTCODE_CHARS environment variable
config:generate-secret [DEPRECATED] Generates a random secret string that can be used for JWT token encryption
short-url
short-url:delete [short-code:delete] Deletes a short URL
short-url:generate [shortcode:generate|short-code:generate] Generates a short URL for provided long URL and returns it

View File

@@ -2,11 +2,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
use function chdir;
use function dirname;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class)->run();
chdir(dirname(__DIR__));
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(false);

14
bin/test/run-api-tests.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
set -e
export APP_ENV=test
# Try to stop server just in case it hanged in last execution
vendor/bin/zend-expressive-swoole stop
echo 'Starting server...'
vendor/bin/zend-expressive-swoole start -d
sleep 2
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox
vendor/bin/zend-expressive-swoole stop

View File

@@ -2,11 +2,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
use function chdir;
use function dirname;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class, ['isUpdate' => true])->run();
chdir(dirname(__DIR__));
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(true);

View File

@@ -17,6 +17,7 @@ echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=bin/test \
--exclude=data/infra \
--exclude=data/travis \
--exclude=data/migrations_template.txt \
@@ -28,11 +29,11 @@ rsync -av * "${builtcontent}" \
--exclude=docs \
--exclude=indocker \
--exclude=docker* \
--exclude=func_tests_bootstrap.php \
--exclude=php* \
--exclude=infection.json \
--exclude=phpstan.neon \
--exclude=config/autoload/*local* \
--exclude=config/test \
--exclude=**/test* \
--exclude=build*
cd "${builtcontent}"
@@ -40,7 +41,7 @@ cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
${composerBin} self-update
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
${composerBin} install --no-dev --optimize-autoloader --apcu-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'

View File

@@ -29,7 +29,7 @@
"lstrojny/functional-php": "^1.8",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"shlinkio/shlink-installer": "^1.1",
"symfony/console": "^4.2",
"symfony/filesystem": "^4.2",
"symfony/lock": "^4.2",
@@ -37,7 +37,7 @@
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-diactoros": "^2.0",
"zendframework/zend-diactoros": "^2.1.1",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
@@ -51,12 +51,14 @@
},
"require-dev": {
"devster/ubench": "^2.0",
"doctrine/data-fixtures": "^1.3",
"filp/whoops": "^2.0",
"infection/infection": "^0.11.0",
"phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.3",
"shlinkio/php-coding-standard": "~1.0.0",
"infection/infection": "^0.12.2",
"phpstan/phpstan": "^0.11.2",
"phpunit/phpcov": "^6.0@dev || ^5.0",
"phpunit/phpunit": "^8.0 || ^7.5",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~1.1.0",
"symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.2",
"zendframework/zend-component-installer": "^2.1",
@@ -67,8 +69,7 @@
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
"Shlinkio\\Shlink\\Installer\\": "module/Installer/src"
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
},
"files": [
"module/Common/functions/functions.php"
@@ -78,28 +79,24 @@
"psr-4": {
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
"ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api",
"ShlinkioTest\\Shlink\\Core\\": [
"module/Core/test",
"module/Core/test-func"
"module/Core/test-db"
],
"ShlinkioTest\\Shlink\\Common\\": [
"module/Common/test",
"module/Common/test-func"
],
"ShlinkioTest\\Shlink\\Installer\\": "module/Installer/test"
"module/Common/test-db"
]
}
},
"scripts": {
"check": [
"ci": [
"@cs",
"@stan",
"@test:ci",
"@infect:ci"
],
"ci": [
"echo \"This command is DEPRECATED. Use check instead\"",
"@check"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
@@ -107,15 +104,18 @@
"test": [
"@test:unit",
"@test:func"
"@test:db",
"@test:api"
],
"test:ci": [
"@test:unit:ci",
"@test:func"
"@test:db",
"@test:api"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov",
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml",
"test:func": "phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-func.xml --coverage-php build/coverage-func.cov",
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml --testdox",
"test:db": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
"test:api": "bin/test/run-api-tests.sh",
"test:pretty": [
"@test",
@@ -123,9 +123,9 @@
],
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --coverage-html build/coverage --order-by=random",
"infect": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered",
"infect:ci": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=65 --log-verbosity=2 --only-covered --show-mutations",
"infect": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"
@@ -141,12 +141,14 @@
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:func": "<fg=blue;options=bold>Runs functional test suites (covering entity repositories)</>",
"test:db": "<fg=blue;options=bold>Runs database test suites (covering entity repositories)</>",
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>"
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>",
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>"
},
"config": {
"sort-packages": true

View File

@@ -1,12 +1,23 @@
<?php
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Psr\Log;
return [
'dependencies' => [
'lazy_services' => [
'write_proxy_files' => false,
],
'initializers' => [
function (ContainerInterface $container, $instance) {
if ($instance instanceof Log\LoggerAwareInterface) {
$instance->setLogger($container->get(Log\LoggerInterface::class));
}
},
],
],
];

View File

@@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
use Zend\Expressive\Container\WhoopsErrorResponseGeneratorFactory;
return [

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Installer\Config\Plugin;
return [
'installer_plugins_expected_config' => [
Plugin\LanguageConfigCustomizer::class => [
Plugin\LanguageConfigCustomizer::DEFAULT_LANG,
],
Plugin\UrlShortenerConfigCustomizer::class => [
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
Plugin\UrlShortenerConfigCustomizer::CHARS,
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
Plugin\UrlShortenerConfigCustomizer::ENABLE_NOT_FOUND_REDIRECTION,
Plugin\UrlShortenerConfigCustomizer::NOT_FOUND_REDIRECT_TO,
],
Plugin\ApplicationConfigCustomizer::class => [
Plugin\ApplicationConfigCustomizer::SECRET,
Plugin\ApplicationConfigCustomizer::DISABLE_TRACK_PARAM,
Plugin\ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD,
Plugin\ApplicationConfigCustomizer::VISITS_THRESHOLD,
],
Plugin\DatabaseConfigCustomizer::class => [
Plugin\DatabaseConfigCustomizer::DRIVER,
Plugin\DatabaseConfigCustomizer::NAME,
Plugin\DatabaseConfigCustomizer::USER,
Plugin\DatabaseConfigCustomizer::PASSWORD,
Plugin\DatabaseConfigCustomizer::HOST,
Plugin\DatabaseConfigCustomizer::PORT,
],
],
];

View File

@@ -7,6 +7,7 @@ use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor;
use const PHP_EOL;
return [

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Cocur\Slugify\Slugify;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
return [
'slugify_options' => [
'lowercase' => false,
],
'dependencies' => [
'factories' => [
Slugify::class => ConfigAbstractFactory::class,
],
],
ConfigAbstractFactory::class => [
Slugify::class => ['config.slugify_options'],
],
];

View File

@@ -1,7 +1,8 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use function Shlinkio\Shlink\Common\env;
return [
@@ -11,7 +12,7 @@ return [
'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => env('SHORTENED_URL_HOSTNAME'),
],
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortenerOptions::DEFAULT_CHARS),
'validate_url' => true,
'not_found_short_url' => [
'enable_redirection' => false,

View File

@@ -3,9 +3,9 @@ declare(strict_types=1);
return [
'phpwkhtmltopdf' => [
'wkhtmltopdf' => [
'images' => [
'binary' => 'bin/wkhtmltoimage',
'binary' => __DIR__ . '/../../bin/wkhtmltoimage',
'type' => 'jpg',
],
],

View File

@@ -6,30 +6,8 @@ use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\ServiceManager;
$isTest = false;
foreach ($_SERVER['argv'] as $i => $arg) {
if ($arg === '--test') {
unset($_SERVER['argv'][$i]);
$isTest = true;
break;
}
}
/** @var ContainerInterface|ServiceManager $container */
$container = include __DIR__ . '/container.php';
// If in testing env, override DB connection to use an in-memory sqlite database
if ($isTest) {
$container->setAllowOverride(true);
$config = $container->get('config');
$config['entity_manager']['connection'] = [
'driver' => 'pdo_sqlite',
'path' => realpath(sys_get_temp_dir()) . '/shlink-tests.db',
];
$container->setService('config', $config);
}
/** @var EntityManager $em */
$em = $container->get(EntityManager::class);
return ConsoleRunner::createHelperSet($em);

View File

@@ -7,6 +7,8 @@ use Acelaya\ExpressiveErrorHandler;
use Zend\ConfigAggregator;
use Zend\Expressive;
use function Shlinkio\Shlink\Common\env;
return (new ConfigAggregator\ConfigAggregator([
Expressive\ConfigProvider::class,
Expressive\Router\ConfigProvider::class,
@@ -17,8 +19,10 @@ return (new ConfigAggregator\ConfigAggregator([
Common\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Installer\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
env('APP_ENV') === 'test'
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\ArrayProvider([]),
], 'data/cache/app_config.php'))->getMergedConfig();

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$container = new ServiceManager([
'factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
],
'services' => [
'config' => [
ConfigAbstractFactory::class => [
DatabaseConfigCustomizer::class => [Filesystem::class],
],
],
],
]);
return $container;

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use function file_exists;
use function touch;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(TestHelper::class);
$config = $container->get('config');
$em = $container->get(EntityManager::class);
$testHelper->createTestDb($config['entity_manager']['connection']['path']);
ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client'));
ApiTest\ApiTestCase::setSeedFixturesCallback(function () use ($testHelper, $em, $config) {
$testHelper->seedFixtures($em, $config['data_fixtures'] ?? []);
});

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common;
use Psr\Container\ContainerInterface;
use function file_exists;
use function touch;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$config = $container->get('config');
$container->get(TestHelper::class)->createTestDb($config['entity_manager']['connection']['path']);
DbTest\DatabaseTestCase::setEntityManager($container->get('em'));

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink;
use GuzzleHttp\Client;
use Zend\ConfigAggregator\ConfigAggregator;
use Zend\ServiceManager\Factory\InvokableFactory;
use function sprintf;
use function sys_get_temp_dir;
$swooleTestingHost = '127.0.0.1';
$swooleTestingPort = 9999;
return [
'debug' => true,
ConfigAggregator::ENABLE_CACHE => false,
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'doma.in',
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'host' => $swooleTestingHost,
'port' => $swooleTestingPort,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
],
],
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort),
'http_errors' => false,
]),
],
'factories' => [
Common\TestHelper::class => InvokableFactory::class,
],
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_sqlite',
'path' => sys_get_temp_dir() . '/shlink-tests.db',
// 'path' => __DIR__ . '/../../data/shlink-tests.db',
],
],
'data_fixtures' => [
'paths' => [
__DIR__ . '/../../module/Rest/test-api/Fixtures',
],
],
];

2
data/infra/database_pg/.gitignore vendored Executable file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1,6 +0,0 @@
FROM mysql:5.7
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Enable remote access (default is localhost only, we change this
# otherwise our database would not be reachable from outside the container)
RUN sed -i -e"s/^bind-address\s*=\s*127.0.0.1/bind-address = 0.0.0.0/" /etc/mysql/my.cnf

View File

@@ -1,5 +0,0 @@
FROM nginx:1.11.6-alpine
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
# Delete default nginx vhost
RUN rm /etc/nginx/conf.d/default.conf

View File

@@ -1,6 +1,12 @@
FROM php:7.1.22-fpm-alpine
FROM php:7.3.1-fpm-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV XDEBUG_VERSION "2.7.0RC1"
RUN apk update
# Install common php extensions
@@ -16,17 +22,17 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache --virtual icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache --virtual zlib-dev
RUN apk add --no-cache --virtual libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libmcrypt-dev
RUN docker-php-ext-install mcrypt
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.tar.gz /tmp/phpredis.tar.gz
RUN mkdir -p /usr/src/php/ext/redis\
&& tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1
# configure and install
@@ -38,7 +44,7 @@ RUN rm /tmp/phpredis.tar.gz
# Install memcached extension
RUN apk add --no-cache --virtual cyrus-sasl-dev
RUN apk add --no-cache --virtual libmemcached-dev
ADD https://github.com/php-memcached-dev/php-memcached/archive/php7.tar.gz /tmp/memcached.tar.gz
ADD https://github.com/php-memcached-dev/php-memcached/archive/v$MEMCACHED_VERSION.tar.gz /tmp/memcached.tar.gz
RUN mkdir -p /usr/src/php/ext/memcached\
&& tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1
# configure and install
@@ -48,7 +54,7 @@ RUN docker-php-ext-configure memcached\
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-5.1.3.tgz /tmp/apcu.tar.gz
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
# configure and install
@@ -58,7 +64,7 @@ RUN docker-php-ext-configure apcu\
RUN rm /tmp/apcu.tar.gz
# Install APCu-BC extension
ADD https://pecl.php.net/get/apcu_bc-1.0.3.tgz /tmp/apcu_bc.tar.gz
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu-bc\
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
# configure and install
@@ -72,7 +78,7 @@ RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install xdebug
ADD https://pecl.php.net/get/xdebug-2.5.0 /tmp/xdebug.tar.gz
ADD https://pecl.php.net/get/xdebug-$XDEBUG_VERSION /tmp/xdebug.tar.gz
RUN mkdir -p /usr/src/php/ext/xdebug\
&& tar xf /tmp/xdebug.tar.gz -C /usr/src/php/ext/xdebug --strip-components=1
# configure and install

View File

@@ -1,6 +1,11 @@
FROM php:7.1.22-cli-alpine3.7
FROM php:7.3.1-cli-alpine3.8
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV PREDIS_VERSION 4.2.0
ENV MEMCACHED_VERSION 3.1.3
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
RUN apk update
# Install common php extensions
@@ -16,17 +21,17 @@ RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache --virtual icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache --virtual zlib-dev
RUN apk add --no-cache --virtual libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libmcrypt-dev
RUN docker-php-ext-install mcrypt
RUN apk add --no-cache --virtual libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
# Install redis extension
ADD https://github.com/phpredis/phpredis/archive/3.1.4.tar.gz /tmp/phpredis.tar.gz
ADD https://github.com/phpredis/phpredis/archive/$PREDIS_VERSION.tar.gz /tmp/phpredis.tar.gz
RUN mkdir -p /usr/src/php/ext/redis\
&& tar xf /tmp/phpredis.tar.gz -C /usr/src/php/ext/redis --strip-components=1
# configure and install
@@ -38,7 +43,7 @@ RUN rm /tmp/phpredis.tar.gz
# Install memcached extension
RUN apk add --no-cache --virtual cyrus-sasl-dev
RUN apk add --no-cache --virtual libmemcached-dev
ADD https://github.com/php-memcached-dev/php-memcached/archive/php7.tar.gz /tmp/memcached.tar.gz
ADD https://github.com/php-memcached-dev/php-memcached/archive/v$MEMCACHED_VERSION.tar.gz /tmp/memcached.tar.gz
RUN mkdir -p /usr/src/php/ext/memcached\
&& tar xf /tmp/memcached.tar.gz -C /usr/src/php/ext/memcached --strip-components=1
# configure and install
@@ -48,7 +53,7 @@ RUN docker-php-ext-configure memcached\
RUN rm /tmp/memcached.tar.gz
# Install APCu extension
ADD https://pecl.php.net/get/apcu-5.1.3.tgz /tmp/apcu.tar.gz
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
&& tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
# configure and install
@@ -58,7 +63,7 @@ RUN docker-php-ext-configure apcu\
RUN rm /tmp/apcu.tar.gz
# Install APCu-BC extension
ADD https://pecl.php.net/get/apcu_bc-1.0.3.tgz /tmp/apcu_bc.tar.gz
ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu-bc\
&& tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
# configure and install

View File

@@ -1,4 +1,4 @@
version: '2'
version: '3'
services:
shlink_php:
@@ -12,3 +12,15 @@ services:
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
shlink_db_postgres:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro

View File

@@ -1,17 +1,15 @@
version: '2'
version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
build:
context: .
dockerfile: ./data/infra/nginx.Dockerfile
image: nginx:1.15.9-alpine
ports:
- "8000:80"
volumes:
- ./:/home/shlink/www
- ./docs:/home/shlink/www/public/docs
- ./data/infra/vhost.conf:/etc/nginx/conf.d/shlink-vhost.conf
- ./data/infra/vhost.conf:/etc/nginx/conf.d/default.conf
links:
- shlink_php
@@ -25,6 +23,7 @@ services:
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
links:
- shlink_db
- shlink_db_postgres
shlink_swoole:
container_name: shlink_swoole
@@ -37,12 +36,11 @@ services:
- ./:/home/shlink
links:
- shlink_db
- shlink_db_postgres
shlink_db:
container_name: shlink_db
build:
context: .
dockerfile: ./data/infra/db.Dockerfile
image: mysql:5.7
ports:
- "3307:3306"
volumes:
@@ -51,3 +49,16 @@ services:
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: shlink
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:10.7-alpine
ports:
- "5433:5432"
volumes:
- ./:/home/shlink/www
- ./data/infra/database_pg:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: root
POSTGRES_DB: shlink
PGDATA: /var/lib/postgresql/data/pgdata

View File

@@ -0,0 +1,31 @@
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"pass",
"fail"
],
"description": "The status of the service"
},
"version": {
"type": "string",
"description": "Shlink version"
},
"links": {
"type": "object",
"properties": {
"about": {
"type": "string",
"description": "About shlink"
},
"project": {
"type": "string",
"description": "Shlink project repository"
}
},
"description": "A list of links"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"get": {
"operationId": "health",
"tags": [
"Monitoring"
],
"summary": "Check healthiness",
"description": "Checks the healthiness of the service, making sure it can access required resources.",
"responses": {
"200": {
"description": "The passing health status",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "pass",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
},
"503": {
"description": "The failing health status",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Health.json"
}
}
},
"examples": {
"application/json": {
"status": "fail",
"version": "1.16.0",
"links": {
"about": "https://shlink.io",
"project": "https://github.com/shlinkio/shlink"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -151,7 +151,7 @@
"Short URLs"
],
"summary": "Create short URL",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"description": "Creates a new short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.<br></br>**Param findIfExists:**: Starting with v1.16, this new param allows to force shlink to return existing short URLs when found based on provided params, instead of creating a new one. However, it might add complexity and have unexpected outputs.\n\nThese are the use cases:\n* Only the long URL is provided: It will return the newest match or create a new short URL if none is found.\n* Long url and custom slug are provided: It will return the short URL when both params match, return an error when the slug is in use for another long URL, or create a new short URL otherwise.\n* Any of the above but including other params (tags, validSince, validUntil, maxVisits): It will behave the same as the previous two cases, but it will try to exactly match existing results using all the params. If any of them does not match, it will try to create a new short URL.",
"security": [
{
"ApiKey": []
@@ -197,6 +197,10 @@
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"findIfExists": {
"description": "Will force existing matching URL to be returned if found, instead of creating a new one",
"type": "boolean"
}
}
}

View File

@@ -56,6 +56,10 @@
"name": "Visits",
"description": "Operations to manage visits on short URLs"
},
{
"name": "Monitoring",
"description": "Public endpoints designed to monitor the service"
},
{
"name": "URL Shortener",
"description": "Non-rest endpoints, used to be publicly exposed"
@@ -88,6 +92,10 @@
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/rest/health": {
"$ref": "paths/health.json"
},
"/{shortCode}": {
"$ref": "paths/{shortCode}.json"
},

View File

@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
use Symfony\Component\Process\Process;
use Zend\ServiceManager\ServiceManager;
// Create an empty .env file
if (! file_exists('.env')) {
touch('.env');
}
$shlinkDbPath = realpath(sys_get_temp_dir()) . '/shlink-tests.db';
if (file_exists($shlinkDbPath)) {
unlink($shlinkDbPath);
}
/** @var ServiceManager $sm */
$sm = require __DIR__ . '/config/container.php';
$sm->setAllowOverride(true);
$config = $sm->get('config');
$config['entity_manager']['connection'] = [
'driver' => 'pdo_sqlite',
'path' => $shlinkDbPath,
];
$sm->setService('config', $config);
// Create database
$process = new Process(['vendor/bin/doctrine', 'orm:schema-tool:create', '--no-interaction', '-q', '--test'], __DIR__);
$process->inheritEnvironmentVariables()
->mustRun();
DatabaseTestCase::$em = $sm->get('em');

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use InvalidArgumentException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
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;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DisableKeyCommand extends Command
@@ -32,7 +34,7 @@ class DisableKeyCommand extends Command
->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$apiKey = $input->getArgument('apiKey');
$io = new SymfonyStyle($input, $output);
@@ -40,8 +42,10 @@ class DisableKeyCommand extends Command
try {
$this->apiKeyService->disable($apiKey);
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error(sprintf('API key "%s" does not exist.', $apiKey));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class GenerateKeyCommand extends Command
@@ -38,11 +40,12 @@ class GenerateKeyCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? Chronos::parse($expirationDate) : null);
(new SymfonyStyle($input, $output))->success(sprintf('Generated API key: "%s"', $apiKey));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@@ -10,6 +11,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use function array_filter;
use function array_map;
use function sprintf;
@@ -44,7 +46,7 @@ class ListKeysCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$enabledOnly = $input->getOption('enabledOnly');
@@ -66,6 +68,7 @@ class ListKeysCommand extends Command
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',
]), $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function determineMessagePattern(ApiKey $apiKey): string

View File

@@ -3,14 +3,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
use function str_shuffle;
/** @deprecated */
class GenerateCharsetCommand extends Command
{
public const NAME = 'config:generate-charset';
@@ -20,15 +23,17 @@ class GenerateCharsetCommand extends Command
$this
->setName(self::NAME)
->setDescription(sprintf(
'Generates a character set sample just by shuffling the default one, "%s". '
'[DEPRECATED] Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable',
UrlShortener::DEFAULT_CHARS
));
UrlShortenerOptions::DEFAULT_CHARS
))
->setHelp('<fg=red;options=bold>This command is deprecated. Better leave shlink generate the charset.</>');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
$charSet = str_shuffle(UrlShortenerOptions::DEFAULT_CHARS);
(new SymfonyStyle($input, $output))->success(sprintf('Character set: "%s"', $charSet));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,13 +3,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Config;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
/** @deprecated */
class GenerateSecretCommand extends Command
{
use StringUtilsTrait;
@@ -20,12 +23,16 @@ class GenerateSecretCommand extends Command
{
$this
->setName(self::NAME)
->setDescription('[DEPRECATED] Generates a random secret string that can be used for JWT token encryption');
->setDescription('[DEPRECATED] Generates a random secret string that can be used for JWT token encryption')
->setHelp(
'<fg=red;options=bold>This command is deprecated. Better leave shlink generate the secret key.</>'
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$secret = $this->generateRandomString(32);
(new SymfonyStyle($input, $output))->success(sprintf('Secret key: "%s"', $secret));
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -11,6 +12,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteShortUrlCommand extends Command
@@ -43,7 +45,7 @@ class DeleteShortUrlCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
@@ -51,14 +53,16 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS;
} catch (Exception\InvalidShortCodeException $e) {
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
return ExitCodes::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
$this->retry($io, $shortCode, $e);
return $this->retry($io, $shortCode, $e);
}
}
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): void
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): int
{
$warningMsg = sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.',
@@ -73,6 +77,8 @@ class DeleteShortUrlCommand extends Command
} else {
$io->warning('Short URL was not deleted.');
}
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
}
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -10,6 +11,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class GeneratePreviewCommand extends Command
@@ -39,7 +41,7 @@ class GeneratePreviewCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$page = 1;
do {
@@ -52,6 +54,7 @@ class GeneratePreviewCommand extends Command
} while ($page <= $shortUrls->count());
(new SymfonyStyle($input, $output))->success('Finished processing all URLs');
return ExitCodes::EXIT_SUCCESS;
}
private function processUrl($url, OutputInterface $output): void

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Util\ShortUrlBuilderTrait;
use Symfony\Component\Console\Command\Command;
@@ -15,8 +17,11 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Diactoros\Uri;
use function array_merge;
use function explode;
use function array_map;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
use function sprintf;
class GenerateShortUrlCommand extends Command
@@ -76,6 +81,12 @@ class GenerateShortUrlCommand extends Command
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.'
)
->addOption(
'findIfExists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.'
);
}
@@ -93,22 +104,17 @@ class GenerateShortUrlCommand extends Command
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$longUrl = $input->getArgument('longUrl');
if (empty($longUrl)) {
$io->error('A URL was not provided!');
return;
return ExitCodes::EXIT_FAILURE;
}
$tags = $input->getOption('tags');
$processedTags = [];
foreach ($tags as $key => $tag) {
$explodedTags = explode(',', $tag);
$processedTags = array_merge($processedTags, $explodedTags);
}
$tags = $processedTags;
$explodeWithComma = curry('explode')(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
@@ -116,10 +122,13 @@ class GenerateShortUrlCommand extends Command
$shortCode = $this->urlShortener->urlToShortCode(
new Uri($longUrl),
$tags,
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null
ShortUrlMeta::createFromParams(
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null,
$input->getOption('findIfExists')
)
)->getShortCode();
$shortUrl = $this->buildShortUrl($this->domainConfig, $shortCode);
@@ -127,12 +136,15 @@ class GenerateShortUrlCommand extends Command
sprintf('Processed long URL: <info>%s</info>', $longUrl),
sprintf('Generated short URL: <info>%s</info>', $shortUrl),
]);
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidUrlException $e) {
$io->error(sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl));
return ExitCodes::EXIT_FAILURE;
} catch (NonUniqueSlugException $e) {
$io->error(
sprintf('Provided slug "%s" is already in use by another URL. Try with a different one.', $customSlug)
);
return ExitCodes::EXIT_FAILURE;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
@@ -16,6 +17,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Stdlib\ArrayUtils;
use function array_map;
use function Functional\select_keys;
@@ -68,7 +70,7 @@ class GetVisitsCommand extends Command
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate');
@@ -83,6 +85,7 @@ class GetVisitsCommand extends Command
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
}, $visits);
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
return ExitCodes::EXIT_SUCCESS;
}
private function getDateOption(InputInterface $input, $key)

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
@@ -15,6 +16,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Paginator\Paginator;
use function array_values;
use function count;
use function explode;
@@ -74,7 +76,7 @@ class ListShortUrlsCommand extends Command
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
@@ -95,6 +97,7 @@ class ListShortUrlsCommand extends Command
$io->newLine();
$io->success('Short URLs properly listed');
return ExitCodes::EXIT_SUCCESS;
}
private function renderPage(

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -11,6 +12,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class ResolveUrlCommand extends Command
@@ -50,7 +52,7 @@ class ResolveUrlCommand extends Command
}
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
@@ -58,10 +60,13 @@ class ResolveUrlCommand extends Command
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidShortCodeException $e) {
$io->error(sprintf('Provided short code "%s" has an invalid format.', $shortCode));
return ExitCodes::EXIT_FAILURE;
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -36,17 +37,18 @@ class CreateTagCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return;
return ExitCodes::EXIT_WARNING;
}
$this->tagService->createTags($tagNames);
$io->success('Tags properly created');
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -36,17 +37,18 @@ class DeleteTagsCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$tagNames = $input->getOption('name');
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return;
return ExitCodes::EXIT_WARNING;
}
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
return ExitCodes::EXIT_SUCCESS;
}
}

View File

@@ -3,12 +3,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map;
class ListTagsCommand extends Command
@@ -31,9 +33,10 @@ class ListTagsCommand extends Command
->setDescription('Lists existing tags.');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}
private function getTagsRows(): array

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
@@ -10,6 +11,7 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class RenameTagCommand extends Command
@@ -34,7 +36,7 @@ class RenameTagCommand extends Command
->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$oldName = $input->getArgument('oldName');
@@ -43,8 +45,10 @@ class RenameTagCommand extends Command
try {
$this->tagService->renameTag($oldName, $newName);
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf('A tag with name "%s" was not found', $oldName));
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -3,8 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpLocationResolverInterface;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
@@ -15,6 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Lock\Factory as Locker;
use function sprintf;
class ProcessVisitsCommand extends Command
@@ -48,7 +51,7 @@ class ProcessVisitsCommand extends Command
->setDescription('Processes visits where location is not set yet');
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$this->output = $output;
$io = new SymfonyStyle($input, $output);
@@ -56,38 +59,43 @@ class ProcessVisitsCommand extends Command
$lock = $this->locker->createLock(self::NAME);
if (! $lock->acquire()) {
$io->warning(sprintf('There is already an instance of the "%s" command in execution', self::NAME));
return;
return ExitCodes::EXIT_WARNING;
}
try {
$this->visitService->locateVisits(
$this->visitService->locateUnlocatedVisits(
[$this, 'getGeolocationDataForVisit'],
function (VisitLocation $location) use ($output) {
$output->writeln(sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName()));
if (! $location->isEmpty()) {
$output->writeln(
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName())
);
}
}
);
$io->success('Finished processing all IPs');
} finally {
$lock->release();
return ExitCodes::EXIT_SUCCESS;
}
}
public function getGeolocationDataForVisit(Visit $visit): array
public function getGeolocationDataForVisit(Visit $visit): Location
{
if (! $visit->hasRemoteAddr()) {
$this->output->writeln(
'<comment>Ignored visit with no IP address</comment>',
OutputInterface::VERBOSITY_VERBOSE
);
throw new IpCannotBeLocatedException('Ignored visit with no IP address');
throw IpCannotBeLocatedException::forEmptyAddress();
}
$ipAddr = $visit->getRemoteAddr();
$this->output->write(sprintf('Processing IP <fg=blue>%s</>', $ipAddr));
if ($ipAddr === IpAddress::LOCALHOST) {
$this->output->writeln(' [<comment>Ignored localhost address</comment>]');
throw new IpCannotBeLocatedException('Ignored localhost address');
throw IpCannotBeLocatedException::forLocalhost();
}
try {
@@ -98,7 +106,7 @@ class ProcessVisitsCommand extends Command
$this->getApplication()->renderException($e, $this->output);
}
throw new IpCannotBeLocatedException('An error occurred while locating IP', $e->getCode(), $e);
throw IpCannotBeLocatedException::forError($e);
}
}
}

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Shlinkio\Shlink\Common\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Console\Command\Command;
@@ -35,7 +36,7 @@ class UpdateDbCommand extends Command
);
}
protected function execute(InputInterface $input, OutputInterface $output): void
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$progressBar = new ProgressBar($output);
@@ -51,6 +52,7 @@ class UpdateDbCommand extends Command
$io->writeln('');
$io->success('GeoLite2 database properly updated');
return ExitCodes::EXIT_SUCCESS;
} catch (RuntimeException $e) {
$progressBar->finish();
$io->writeln('');
@@ -59,6 +61,7 @@ class UpdateDbCommand extends Command
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Util;
final class ExitCodes
{
public const EXIT_SUCCESS = 0;
public const EXIT_FAILURE = -1;
public const EXIT_WARNING = 1;
}

View File

@@ -18,7 +18,7 @@ class DisableKeyCommandTest extends TestCase
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new DisableKeyCommand($this->apiKeyService->reveal());
@@ -27,9 +27,7 @@ class DisableKeyCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function providedApiKeyIsDisabled()
{
$apiKey = 'abcd1234';
@@ -41,12 +39,10 @@ class DisableKeyCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('API key "abcd1234" properly disabled', $output);
$this->assertStringContainsString('API key "abcd1234" properly disabled', $output);
}
/**
* @test
*/
/** @test */
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
@@ -58,7 +54,7 @@ class DisableKeyCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('API key "abcd1234" does not exist.', $output);
$this->assertStringContainsString('API key "abcd1234" does not exist.', $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View File

@@ -20,7 +20,7 @@ class GenerateKeyCommandTest extends TestCase
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new GenerateKeyCommand($this->apiKeyService->reveal());
@@ -29,9 +29,7 @@ class GenerateKeyCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function noExpirationDateIsDefinedIfNotProvided()
{
$create = $this->apiKeyService->create(null)->willReturn(new ApiKey());
@@ -41,13 +39,11 @@ class GenerateKeyCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Generated API key: ', $output);
$this->assertStringContainsString('Generated API key: ', $output);
$create->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()

View File

@@ -18,7 +18,7 @@ class ListKeysCommandTest extends TestCase
/** @var ObjectProphecy */
private $apiKeyService;
public function setUp()
public function setUp(): void
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new ListKeysCommand($this->apiKeyService->reveal());
@@ -27,10 +27,8 @@ class ListKeysCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function everythingIsListedIfEnabledOnlyIsNotProvided()
/** @test */
public function everythingIsListedIfEnabledOnlyIsNotProvided(): void
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
@@ -38,22 +36,18 @@ class ListKeysCommandTest extends TestCase
new ApiKey(),
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => ListKeysCommand::NAME,
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertContains('Is enabled', $output);
$this->assertContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
$this->assertStringContainsString('Key', $output);
$this->assertStringContainsString('Is enabled', $output);
$this->assertStringContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
}
/**
* @test
*/
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided()
/** @test */
public function onlyEnabledKeysAreListedIfEnabledOnlyIsProvided(): void
{
$this->apiKeyService->listKeys(true)->willReturn([
(new ApiKey())->disable(),
@@ -61,15 +55,14 @@ class ListKeysCommandTest extends TestCase
])->shouldBeCalledOnce();
$this->commandTester->execute([
'command' => ListKeysCommand::NAME,
'--enabledOnly' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Key', $output);
$this->assertNotContains('Is enabled', $output);
$this->assertNotContains(' +++ ', $output);
$this->assertNotContains(' --- ', $output);
$this->assertContains('Expiration date', $output);
$this->assertStringContainsString('Key', $output);
$this->assertStringNotContainsString('Is enabled', $output);
$this->assertStringNotContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use function implode;
use function sort;
use function str_split;
@@ -16,7 +17,7 @@ class GenerateCharsetCommandTest extends TestCase
/** @var CommandTester */
private $commandTester;
public function setUp()
public function setUp(): void
{
$command = new GenerateCharsetCommand();
$app = new Application();
@@ -25,9 +26,7 @@ class GenerateCharsetCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function charactersAreGeneratedFromDefault()
{
$prefix = 'Character set: ';
@@ -38,7 +37,7 @@ class GenerateCharsetCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length
$this->assertContains($prefix, $output);
$this->assertStringContainsString($prefix, $output);
}
protected function orderStringLetters($string)

View File

@@ -11,17 +11,20 @@ use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use const PHP_EOL;
use function array_pop;
use function sprintf;
class DeleteShortCodeCommandTest extends TestCase
class DeleteShortUrlCommandTest extends TestCase
{
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $service;
public function setUp()
public function setUp(): void
{
$this->service = $this->prophesize(DeleteShortUrlServiceInterface::class);
@@ -32,10 +35,8 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function successMessageIsPrintedIfUrlIsProperlyDeleted()
/** @test */
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function () {
@@ -44,14 +45,15 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$this->assertStringContainsString(
sprintf('Short URL with short code "%s" successfully deleted.', $shortCode),
$output
);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function invalidShortCodePrintsMessage()
/** @test */
public function invalidShortCodePrintsMessage(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
@@ -61,15 +63,19 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$this->assertStringContainsString(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideRetryDeleteAnswers
*/
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted()
{
public function deleteIsRetriedWhenThresholdIsReachedAndQuestionIsAccepted(
array $retryAnswer,
int $expectedDeleteCalls,
string $expectedMessage
): void {
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) {
@@ -80,23 +86,28 @@ class DeleteShortCodeCommandTest extends TestCase
}
}
);
$this->commandTester->setInputs(['yes']);
$this->commandTester->setInputs($retryAnswer);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf(
$this->assertStringContainsString(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledTimes(2);
$this->assertStringContainsString($expectedMessage, $output);
$deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls);
}
/**
* @test
*/
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined()
public 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 */
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
@@ -107,11 +118,11 @@ class DeleteShortCodeCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertContains(sprintf(
$this->assertStringContainsString(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
$shortCode
), $output);
$this->assertContains('Short URL was not deleted.', $output);
$this->assertStringContainsString('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
use function count;
use function substr_count;
@@ -27,7 +28,7 @@ class GeneratePreviewCommandTest extends TestCase
/** @var ObjectProphecy */
private $shortUrlService;
public function setUp()
public function setUp(): void
{
$this->previewGenerator = $this->prophesize(PreviewGenerator::class);
$this->shortUrlService = $this->prophesize(ShortUrlService::class);
@@ -39,9 +40,7 @@ class GeneratePreviewCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function previewsForEveryUrlAreGenerated()
{
$paginator = $this->createPaginator([
@@ -60,18 +59,16 @@ class GeneratePreviewCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Processing URL http://foo.com', $output);
$this->assertContains('Processing URL https://bar.com', $output);
$this->assertContains('Processing URL http://baz.com/something', $output);
$this->assertContains('Finished processing all URLs', $output);
$this->assertStringContainsString('Processing URL http://foo.com', $output);
$this->assertStringContainsString('Processing URL https://bar.com', $output);
$this->assertStringContainsString('Processing URL http://baz.com/something', $output);
$this->assertStringContainsString('Finished processing all URLs', $output);
$generatePreview1->shouldHaveBeenCalledOnce();
$generatePreview2->shouldHaveBeenCalledOnce();
$generatePreview3->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function exceptionWillOutputError()
{
$items = [

View File

@@ -22,7 +22,7 @@ class GenerateShortUrlCommandTest extends TestCase
/** @var ObjectProphecy */
private $urlShortener;
public function setUp()
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), [
@@ -34,9 +34,7 @@ class GenerateShortUrlCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn(
@@ -50,13 +48,11 @@ class GenerateShortUrlCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('http://foo.com/abc123', $output);
$this->assertStringContainsString('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
@@ -67,15 +63,13 @@ class GenerateShortUrlCommandTest extends TestCase
'longUrl' => 'http://domain.com/invalid',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains(
$this->assertStringContainsString(
'Provided URL "http://domain.com/invalid" is invalid.',
$output
);
}
/**
* @test
*/
/** @test */
public function properlyProcessesProvidedTags()
{
$urlToShortCode = $this->urlShortener->urlToShortCode(
@@ -90,11 +84,11 @@ class GenerateShortUrlCommandTest extends TestCase
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar',
'--tags' => ['foo,bar', 'baz', 'boo,zar'],
'--tags' => ['foo,bar', 'baz', 'boo,zar,baz'],
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('http://foo.com/abc123', $output);
$this->assertStringContainsString('http://foo.com/abc123', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
}

View File

@@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
@@ -27,7 +28,7 @@ class GetVisitsCommandTest extends TestCase
/** @var ObjectProphecy */
private $visitsTracker;
public function setUp()
public function setUp(): void
{
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
$command = new GetVisitsCommand($this->visitsTracker->reveal());
@@ -36,9 +37,7 @@ class GetVisitsCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function noDateFlagsTriesToListWithoutDateRange()
{
$shortCode = 'abc123';
@@ -52,9 +51,7 @@ class GetVisitsCommandTest extends TestCase
]);
}
/**
* @test
*/
/** @test */
public function providingDateFlagsTheListGetsFiltered()
{
$shortCode = 'abc123';
@@ -75,16 +72,14 @@ class GetVisitsCommandTest extends TestCase
]);
}
/**
* @test
*/
public function outputIsProperlyGenerated()
/** @test */
public function outputIsProperlyGenerated(): void
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
new Paginator(new ArrayAdapter([
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
new VisitLocation(['country_name' => 'Spain'])
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, ''))
),
]))
)->shouldBeCalledOnce();
@@ -94,8 +89,8 @@ class GetVisitsCommandTest extends TestCase
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('foo', $output);
$this->assertContains('Spain', $output);
$this->assertContains('bar', $output);
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('Spain', $output);
$this->assertStringContainsString('bar', $output);
}
}

View File

@@ -21,7 +21,7 @@ class ListShortUrlsCommandTest extends TestCase
/** @var ObjectProphecy */
private $shortUrlService;
public function setUp()
public function setUp(): void
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
@@ -30,9 +30,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function noInputCallsListJustOnce()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
@@ -42,9 +40,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute(['command' => 'shortcode:list']);
}
/**
* @test
*/
/** @test */
public function loadingMorePagesCallsListMoreTimes()
{
// The paginator will return more than one page
@@ -61,14 +57,12 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute(['command' => 'shortcode:list']);
$output = $this->commandTester->getDisplay();
$this->assertContains('Continue with page 2?', $output);
$this->assertContains('Continue with page 3?', $output);
$this->assertContains('Continue with page 4?', $output);
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringContainsString('Continue with page 3?', $output);
$this->assertStringContainsString('Continue with page 4?', $output);
}
/**
* @test
*/
/** @test */
public function havingMorePagesButAnsweringNoCallsListJustOnce()
{
// The paginator will return more than one page
@@ -84,18 +78,16 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute(['command' => 'shortcode:list']);
$output = $this->commandTester->getDisplay();
$this->assertContains('url_1', $output);
$this->assertContains('url_9', $output);
$this->assertNotContains('url_10', $output);
$this->assertNotContains('url_20', $output);
$this->assertNotContains('url_30', $output);
$this->assertContains('Continue with page 2?', $output);
$this->assertNotContains('Continue with page 3?', $output);
$this->assertStringContainsString('url_1', $output);
$this->assertStringContainsString('url_9', $output);
$this->assertStringNotContainsString('url_10', $output);
$this->assertStringNotContainsString('url_20', $output);
$this->assertStringNotContainsString('url_30', $output);
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringNotContainsString('Continue with page 3?', $output);
}
/**
* @test
*/
/** @test */
public function passingPageWillMakeListStartOnThatPage()
{
$page = 5;
@@ -109,9 +101,7 @@ class ListShortUrlsCommandTest extends TestCase
]);
}
/**
* @test
*/
/** @test */
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
@@ -123,6 +113,6 @@ class ListShortUrlsCommandTest extends TestCase
'--showTags' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tags', $output);
$this->assertStringContainsString('Tags', $output);
}
}

View File

@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
@@ -21,7 +22,7 @@ class ResolveUrlCommandTest extends TestCase
/** @var ObjectProphecy */
private $urlShortener;
public function setUp()
public function setUp(): void
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new ResolveUrlCommand($this->urlShortener->reveal());
@@ -31,9 +32,7 @@ class ResolveUrlCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function correctShortCodeResolvesUrl()
{
$shortCode = 'abc123';
@@ -50,9 +49,7 @@ class ResolveUrlCommandTest extends TestCase
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
/**
* @test
*/
/** @test */
public function incorrectShortCodeOutputsErrorMessage()
{
$shortCode = 'abc123';
@@ -64,12 +61,10 @@ class ResolveUrlCommandTest extends TestCase
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Provided short code "' . $shortCode . '" could not be found.', $output);
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
}
/**
* @test
*/
/** @test */
public function wrongShortCodeFormatOutputsErrorMessage()
{
$shortCode = 'abc123';
@@ -81,6 +76,6 @@ class ResolveUrlCommandTest extends TestCase
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Provided short code "' . $shortCode . '" has an invalid format.', $output);
$this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output);
}
}

View File

@@ -5,7 +5,6 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
@@ -19,7 +18,7 @@ class CreateTagCommandTest extends TestCase
/** @var ObjectProphecy */
private $tagService;
public function setUp()
public function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
@@ -30,24 +29,19 @@ class CreateTagCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function errorIsReturnedWhenNoTagsAreProvided()
{
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('You have to provide at least one tag name', $output);
$this->assertStringContainsString('You have to provide at least one tag name', $output);
}
/**
* @test
*/
/** @test */
public function serviceIsInvokedOnSuccess()
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $createTags */
$createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
$this->commandTester->execute([
@@ -55,7 +49,7 @@ class CreateTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tags properly created', $output);
$this->assertStringContainsString('Tags properly created', $output);
$createTags->shouldHaveBeenCalled();
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
@@ -20,7 +19,7 @@ class DeleteTagsCommandTest extends TestCase
/** @var ObjectProphecy */
private $tagService;
public function setUp()
public function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
@@ -31,24 +30,19 @@ class DeleteTagsCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function errorIsReturnedWhenNoTagsAreProvided()
{
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('You have to provide at least one tag name', $output);
$this->assertStringContainsString('You have to provide at least one tag name', $output);
}
/**
* @test
*/
/** @test */
public function serviceIsInvokedOnSuccess()
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $deleteTags */
$deleteTags = $this->tagService->deleteTags($tagNames)->will(function () {
});
@@ -57,7 +51,7 @@ class DeleteTagsCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tags properly deleted', $output);
$this->assertStringContainsString('Tags properly deleted', $output);
$deleteTags->shouldHaveBeenCalled();
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
@@ -21,7 +20,7 @@ class ListTagsCommandTest extends TestCase
/** @var ObjectProphecy */
private $tagService;
public function setUp()
public function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
@@ -32,27 +31,21 @@ class ListTagsCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function noTagsPrintsEmptyMessage()
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('No tags yet', $output);
$this->assertStringContainsString('No tags yet', $output);
$listTags->shouldHaveBeenCalled();
}
/**
* @test
*/
/** @test */
public function listOfTagsIsPrinted()
{
/** @var MethodProphecy $listTags */
$listTags = $this->tagService->listTags()->willReturn([
new Tag('foo'),
new Tag('bar'),
@@ -61,8 +54,8 @@ class ListTagsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('foo', $output);
$this->assertContains('bar', $output);
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('bar', $output);
$listTags->shouldHaveBeenCalled();
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
@@ -22,7 +21,7 @@ class RenameTagCommandTest extends TestCase
/** @var ObjectProphecy */
private $tagService;
public function setUp()
public function setUp(): void
{
$this->tagService = $this->prophesize(TagServiceInterface::class);
@@ -33,14 +32,11 @@ class RenameTagCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function errorIsPrintedIfExceptionIsThrown()
{
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class);
$this->commandTester->execute([
@@ -49,18 +45,15 @@ class RenameTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('A tag with name "foo" was not found', $output);
$this->assertStringContainsString('A tag with name "foo" was not found', $output);
$renameTag->shouldHaveBeenCalled();
}
/**
* @test
*/
/** @test */
public function successIsPrintedIfNoErrorOccurs()
{
$oldName = 'foo';
$newName = 'bar';
/** @var MethodProphecy $renameTag */
$renameTag = $this->tagService->renameTag($oldName, $newName)->willReturn(new Tag($newName));
$this->commandTester->execute([
@@ -69,7 +62,7 @@ class RenameTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Tag properly renamed', $output);
$this->assertStringContainsString('Tag properly renamed', $output);
$renameTag->shouldHaveBeenCalled();
}
}

View File

@@ -9,18 +9,18 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\IpGeolocation\IpApiLocationResolver;
use Shlinkio\Shlink\Common\IpGeolocation\Model\Location;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock;
use Throwable;
use function array_shift;
use function sprintf;
@@ -37,7 +37,7 @@ class ProcessVisitsCommandTest extends TestCase
/** @var ObjectProphecy */
private $lock;
public function setUp()
public function setUp(): void
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
@@ -60,15 +60,13 @@ class ProcessVisitsCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function allPendingVisitsAreProcessed()
/** @test */
public function allPendingVisitsAreProcessed(): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$location = new VisitLocation([]);
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
@@ -77,14 +75,16 @@ class ProcessVisitsCommandTest extends TestCase
$secondCallback($location, $visit);
}
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
Location::emptyInstance()
);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertContains('Processing IP 1.2.3.0', $output);
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
@@ -93,12 +93,12 @@ class ProcessVisitsCommandTest extends TestCase
* @test
* @dataProvider provideIgnoredAddresses
*/
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message)
public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
$location = new VisitLocation([]);
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
@@ -107,41 +107,39 @@ class ProcessVisitsCommandTest extends TestCase
$secondCallback($location, $visit);
}
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
Location::emptyInstance()
);
try {
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
} catch (Throwable $e) {
$output = $this->commandTester->getDisplay();
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
$this->assertContains($message, $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldNotHaveBeenCalled();
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString($message, $output);
if (empty($address)) {
$this->assertStringNotContainsString('Processing IP', $output);
} else {
$this->assertStringContainsString('Processing IP', $output);
}
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldNotHaveBeenCalled();
}
public function provideIgnoredAddresses(): array
public function provideIgnoredAddresses(): iterable
{
return [
['', 'Ignored visit with no IP address'],
[null, 'Ignored visit with no IP address'],
[IpAddress::LOCALHOST, 'Ignored localhost address'],
];
yield 'with empty address' => ['', 'Ignored visit with no IP address'];
yield 'with null address' => [null, 'Ignored visit with no IP address'];
yield 'with localhost address' => [IpAddress::LOCALHOST, 'Ignored localhost address'];
}
/**
* @test
*/
public function errorWhileLocatingIpIsDisplayed()
/** @test */
public function errorWhileLocatingIpIsDisplayed(): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
$location = new VisitLocation([]);
$location = new VisitLocation(Location::emptyInstance());
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
function (array $args) use ($visit, $location) {
$firstCallback = array_shift($args);
$firstCallback($visit);
@@ -152,29 +150,23 @@ class ProcessVisitsCommandTest extends TestCase
);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
try {
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
} catch (Throwable $e) {
$output = $this->commandTester->getDisplay();
$this->commandTester->execute([
'command' => 'visit:process',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$this->assertInstanceOf(IpCannotBeLocatedException::class, $e);
$output = $this->commandTester->getDisplay();
$this->assertContains('An error occurred while locating IP. Skipped', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
$this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function noActionIsPerformedIfLockIsAcquired()
{
$this->lock->acquire()->willReturn(false);
$locateVisits = $this->visitService->locateVisits(Argument::cetera())->will(function () {
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(function () {
});
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([]);
@@ -183,7 +175,7 @@ class ProcessVisitsCommandTest extends TestCase
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertContains(
$this->assertStringContainsString(
sprintf('There is already an instance of the "%s" command', ProcessVisitsCommand::NAME),
$output
);

View File

@@ -19,7 +19,7 @@ class UpdateDbCommandTest extends TestCase
/** @var ObjectProphecy */
private $dbUpdater;
public function setUp()
public function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
@@ -30,9 +30,7 @@ class UpdateDbCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
/** @test */
public function successMessageIsPrintedIfEverythingWorks()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->will(function () {
@@ -41,13 +39,11 @@ class UpdateDbCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('GeoLite2 database properly updated', $output);
$this->assertStringContainsString('GeoLite2 database properly updated', $output);
$download->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
/** @test */
public function errorMessageIsPrintedIfAnExceptionIsThrown()
{
$download = $this->dbUpdater->downloadFreshCopy(Argument::type('callable'))->willThrow(RuntimeException::class);
@@ -55,7 +51,7 @@ class UpdateDbCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertContains('An error occurred while updating GeoLite2 database', $output);
$this->assertStringContainsString('An error occurred while updating GeoLite2 database', $output);
$download->shouldHaveBeenCalledOnce();
}
}

View File

@@ -11,14 +11,12 @@ class ConfigProviderTest extends TestCase
/** @var ConfigProvider */
private $configProvider;
public function setUp()
public function setUp(): void
{
$this->configProvider = new ConfigProvider();
}
/**
* @test
*/
/** @test */
public function confiIsProperlyReturned()
{
$config = ($this->configProvider)();

View File

@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Zend\ServiceManager\ServiceManager;
use function array_merge;
class ApplicationFactoryTest extends TestCase
@@ -18,23 +19,19 @@ class ApplicationFactoryTest extends TestCase
/** @var ApplicationFactory */
private $factory;
public function setUp()
public function setUp(): void
{
$this->factory = new ApplicationFactory();
}
/**
* @test
*/
/** @test */
public function serviceIsCreated()
{
$instance = ($this->factory)($this->createServiceManager(), '');
$this->assertInstanceOf(Application::class, $instance);
}
/**
* @test
*/
/** @test */
public function allCommandsWhichAreServicesAreAdded()
{
$sm = $this->createServiceManager([

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common;
use const JSON_ERROR_NONE;
use function getenv;
use function json_decode as spl_json_decode;
use function json_last_error;

View File

@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Collection;
use function array_key_exists;
use function array_shift;
use function is_array;
final class PathCollection
{
/** @var array */
private $array;
public function __construct(array $array)
{
$this->array = $array;
}
public function pathExists(array $path): bool
{
return $this->checkPathExists($path, $this->array);
}
private function checkPathExists(array $path, array $array): bool
{
// As soon as a step is not found, the path does not exist
$step = array_shift($path);
if (! array_key_exists($step, $array)) {
return false;
}
// Once the path is empty, we have found all the parts in the path
if (empty($path)) {
return true;
}
// If current value is not an array, then we have not found the path
$newArray = $array[$step];
if (! is_array($newArray)) {
return false;
}
return $this->checkPathExists($path, $newArray);
}
/**
* @return mixed
*/
public function getValueInPath(array $path)
{
$array = $this->array;
do {
$step = array_shift($path);
if (! is_array($array) || ! array_key_exists($step, $array)) {
return null;
}
$array = $array[$step];
} while (! empty($path));
return $array;
}
}

View File

@@ -7,7 +7,7 @@ use function sprintf;
class PreviewGenerationException extends RuntimeException
{
public static function fromImageError($error)
public static function fromImageError(string $error): self
{
return new self(sprintf('Error generating a preview image with error: %s', $error));
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exception;
use Throwable;
use function sprintf;
class WrongIpException extends RuntimeException

View File

@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use function sys_get_temp_dir;

View File

@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\AbstractFactoryInterface;
use function array_shift;
use function explode;
use function is_array;

View File

@@ -9,6 +9,7 @@ use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function count;
use function explode;

View File

@@ -26,7 +26,7 @@ class ImageFactory implements FactoryInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['phpwkhtmltopdf'];
$config = $container->get('config')['wkhtmltopdf'];
$image = new Image($config['images'] ?? null);
if ($options['url'] ?? null) {

View File

@@ -18,7 +18,7 @@ class ChainIpLocationResolver implements IpLocationResolverInterface
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
public function resolveIpLocation(string $ipAddress): Model\Location
{
$error = null;

View File

@@ -10,16 +10,8 @@ class EmptyIpLocationResolver implements IpLocationResolverInterface
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
public function resolveIpLocation(string $ipAddress): Model\Location
{
return [
'country_code' => '',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => '',
'longitude' => '',
'time_zone' => '',
];
return Model\Location::emptyInstance();
}
}

View File

@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Symfony\Component\Filesystem\Exception as FilesystemException;
use Symfony\Component\Filesystem\Filesystem;
use Throwable;
use function sprintf;
class DbUpdater implements DbUpdaterInterface

View File

@@ -9,6 +9,7 @@ use GeoIp2\Model\City;
use GeoIp2\Record\Subdivision;
use MaxMind\Db\Reader\InvalidDatabaseException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use function Functional\first;
class GeoLite2LocationResolver implements IpLocationResolverInterface
@@ -24,7 +25,7 @@ class GeoLite2LocationResolver implements IpLocationResolverInterface
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
public function resolveIpLocation(string $ipAddress): Model\Location
{
try {
$city = $this->geoLiteDbReader->city($ipAddress);
@@ -36,19 +37,19 @@ class GeoLite2LocationResolver implements IpLocationResolverInterface
}
}
private function mapFields(City $city): array
private function mapFields(City $city): Model\Location
{
/** @var Subdivision $region */
$region = first($city->subdivisions);
return [
'country_code' => $city->country->isoCode ?? '',
'country_name' => $city->country->name ?? '',
'region_name' => $region->name ?? '',
'city' => $city->city->name ?? '',
'latitude' => $city->location->latitude ?? '',
'longitude' => $city->location->longitude ?? '',
'time_zone' => $city->location->timeZone ?? '',
];
return new Model\Location(
$city->country->isoCode ?? '',
$city->country->name ?? '',
$region->name ?? '',
$city->city->name ?? '',
(float) ($city->location->latitude ?? ''),
(float) ($city->location->longitude ?? ''),
$city->location->timeZone ?? ''
);
}
}

View File

@@ -7,6 +7,7 @@ use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use function Shlinkio\Shlink\Common\json_decode;
use function sprintf;
@@ -25,7 +26,7 @@ class IpApiLocationResolver implements IpLocationResolverInterface
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
public function resolveIpLocation(string $ipAddress): Model\Location
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
@@ -37,16 +38,16 @@ class IpApiLocationResolver implements IpLocationResolverInterface
}
}
private function mapFields(array $entry): array
private function mapFields(array $entry): Model\Location
{
return [
'country_code' => $entry['countryCode'] ?? '',
'country_name' => $entry['country'] ?? '',
'region_name' => $entry['regionName'] ?? '',
'city' => $entry['city'] ?? '',
'latitude' => $entry['lat'] ?? '',
'longitude' => $entry['lon'] ?? '',
'time_zone' => $entry['timezone'] ?? '',
];
return new Model\Location(
(string) ($entry['countryCode'] ?? ''),
(string) ($entry['country'] ?? ''),
(string) ($entry['regionName'] ?? ''),
(string) ($entry['city'] ?? ''),
(float) ($entry['lat'] ?? 0.0),
(float) ($entry['lon'] ?? 0.0),
(string) ($entry['timezone'] ?? '')
);
}
}

View File

@@ -10,5 +10,5 @@ interface IpLocationResolverInterface
/**
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
public function resolveIpLocation(string $ipAddress): Model\Location;
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\IpGeolocation\Model;
final class Location
{
/** @var string */
private $countryCode;
/** @var string */
private $countryName;
/** @var string */
private $regionName;
/** @var string */
private $city;
/** @var float */
private $latitude;
/** @var float */
private $longitude;
/** @var string */
private $timeZone;
public function __construct(
string $countryCode,
string $countryName,
string $regionName,
string $city,
float $latitude,
float $longitude,
string $timeZone
) {
$this->countryCode = $countryCode;
$this->countryName = $countryName;
$this->regionName = $regionName;
$this->city = $city;
$this->latitude = $latitude;
$this->longitude = $longitude;
$this->timeZone = $timeZone;
}
public static function emptyInstance(): self
{
return new self('', '', '', '', 0.0, 0.0, '');
}
public function countryCode(): string
{
return $this->countryCode;
}
public function countryName(): string
{
return $this->countryName;
}
public function regionName(): string
{
return $this->regionName;
}
public function city(): string
{
return $this->city;
}
public function latitude(): float
{
return $this->latitude;
}
public function longitude(): float
{
return $this->longitude;
}
public function timeZone(): string
{
return $this->timeZone;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Logger\Processor;
use const PHP_EOL;
use function str_replace;
use function strpos;

View File

@@ -8,6 +8,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
use Zend\I18n\Translator\Translator;
use function count;
use function explode;

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Common\Paginator\Adapter;
use Shlinkio\Shlink\Common\Repository\PaginableRepositoryInterface;
use Zend\Paginator\Adapter\AdapterInterface;
use function strip_tags;
use function trim;

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Common\Paginator\Util;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils;
use function array_map;
use function sprintf;

View File

@@ -7,6 +7,7 @@ use Fig\Http\Message\StatusCodeInterface as StatusCode;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use function base64_decode;
class PixelResponse extends Response

View File

@@ -7,6 +7,7 @@ use mikehaertl\wkhtmlto\Image;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Image\ImageBuilderInterface;
use Symfony\Component\Filesystem\Filesystem;
use function sprintf;
use function urlencode;
@@ -19,7 +20,7 @@ class PreviewGenerator implements PreviewGeneratorInterface
/** @var Filesystem */
private $filesystem;
public function __construct(ImageBuilderInterface $imageBuilder, Filesystem $filesystem, $location)
public function __construct(ImageBuilderInterface $imageBuilder, Filesystem $filesystem, string $location)
{
$this->location = $location;
$this->imageBuilder = $imageBuilder;
@@ -33,7 +34,6 @@ class PreviewGenerator implements PreviewGeneratorInterface
*/
public function generatePreview(string $url): string
{
/** @var Image $image */
$image = $this->imageBuilder->build(Image::class, ['url' => $url]);
// If the file already exists, return its path

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Util;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use function count;
use function explode;
use function implode;

View File

@@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use Zend\Stdlib\ArrayUtils;
use const FILEINFO_MIME;
trait ResponseUtilsTrait

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Validation;
use Zend\Filter;
use Zend\InputFilter\Input;
use Zend\Validator;
trait InputFactoryTrait
{
private function createInput($name, $required = true): Input
{
$input = new Input($name);
$input->setRequired($required)
->getFilterChain()->attach(new Filter\StripTags())
->attach(new Filter\StringTrim());
return $input;
}
private function createBooleanInput(string $name, bool $required = true): Input
{
$input = $this->createInput($name, $required);
$input->getFilterChain()->attach(new Filter\Boolean());
$input->getValidatorChain()->attach(new Validator\NotEmpty(['type' => [
Validator\NotEmpty::OBJECT,
Validator\NotEmpty::SPACE,
Validator\NotEmpty::NULL,
Validator\NotEmpty::EMPTY_ARRAY,
Validator\NotEmpty::STRING,
]]));
return $input;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Validation;
use Cocur\Slugify;
use Zend\Filter\Exception;
use Zend\Filter\FilterInterface;
class SluggerFilter implements FilterInterface
{
/** @var Slugify\SlugifyInterface */
private $slugger;
public function __construct(?Slugify\SlugifyInterface $slugger = null)
{
$this->slugger = $slugger ?: new Slugify\Slugify(['lowercase' => false]);
}
/**
* Returns the result of filtering $value
*
* @param mixed $value
* @throws Exception\RuntimeException If filtering $value is impossible
* @return mixed
*/
public function filter($value)
{
return ! empty($value) ? $this->slugger->slugify($value) : null;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\ApiTest;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Shlinkio\Shlink\Rest\Authentication\Plugin\ApiKeyHeaderPlugin;
use function Shlinkio\Shlink\Common\json_decode;
use function sprintf;
abstract class ApiTestCase extends TestCase implements StatusCodeInterface, RequestMethodInterface
{
private const REST_PATH_PREFIX = '/rest/v1';
/** @var ClientInterface */
private static $client;
/** @var callable */
private static $seedFixtures;
public static function setApiClient(ClientInterface $client): void
{
self::$client = $client;
}
public static function setSeedFixturesCallback(callable $seedFixtures): void
{
self::$seedFixtures = $seedFixtures;
}
public function setUp(): void
{
if (self::$seedFixtures) {
(self::$seedFixtures)();
}
}
protected function callApi(string $method, string $uri, array $options = []): ResponseInterface
{
return self::$client->request($method, sprintf('%s%s', self::REST_PATH_PREFIX, $uri), $options);
}
protected function callApiWithKey(string $method, string $uri, array $options = []): ResponseInterface
{
$headers = $options[RequestOptions::HEADERS] ?? [];
$headers[ApiKeyHeaderPlugin::HEADER_NAME] = 'valid_api_key';
$options[RequestOptions::HEADERS] = $headers;
return $this->callApi($method, $uri, $options);
}
protected function getJsonResponsePayload(ResponseInterface $resp): array
{
return json_decode((string) $resp->getBody());
}
protected function callShortUrl(string $shortCode): ResponseInterface
{
return self::$client->request(self::METHOD_GET, sprintf('/%s', $shortCode), [
RequestOptions::ALLOW_REDIRECTS => false,
]);
}
}

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