Merge pull request #889 from shlinkio/develop

Release v2.4.0
This commit is contained in:
Alejandro Celaya 2020-11-08 12:27:50 +01:00 committed by GitHub
commit 29bb201581
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
207 changed files with 3501 additions and 2262 deletions

30
.github/workflows/publish-release.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Publish release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Use PHP 7.4
uses: shivammathur/setup-php@v2
with:
php-version: '7.4' # Publish release with lowest supported PHP version
tools: composer
extensions: swoole-4.5.5
- name: Generate release assets
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- name: Publish release with assets
uses: docker://antonyurchenko/git-release:latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ALLOW_TAG_PREFIX: "true"
ALLOW_EMPTY_CHANGELOG: "true"
with:
args: |
build/shlink_*_dist.zip

View file

@ -26,25 +26,13 @@ jobs:
php: '7.4'
env:
- COMPOSER_FLAGS=''
# Deploy release only on smallest supported PHP version
before_deploy:
- rm -f ocular.phar
- ./build.sh ${TRAVIS_TAG#?}
deploy:
- provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
before_install:
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- phpenv config-rm xdebug.ini || return 0
- sudo ./data/infra/ci/install-ms-odbc.sh
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
- yes | pecl install pdo_sqlsrv swoole-4.5.2
- yes | pecl install pdo_sqlsrv-5.9.0preview1 swoole-4.5.5 pcov
install:
- composer self-update
@ -56,12 +44,13 @@ before_script:
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep Dockerfile)
script:
- bin/test/run-api-tests.sh --coverage-php build/coverage-api.cov && composer ci
- composer ci
- bin/test/run-api-tests.sh
- if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi
after_success:
- rm -f build/clover.xml
- wget https://phar.phpunit.de/phpcov-7.0.2.phar
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
- php phpcov-7.0.2.phar merge build --clover build/clover.xml
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,13 @@
FROM php:7.4.9-alpine3.12 as base
FROM php:7.4.11-alpine3.12 as base
ARG SHLINK_VERSION=2.2.2
ARG SHLINK_VERSION=2.3.0
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.5.2
ENV SWOOLE_VERSION 4.5.5
ENV LC_ALL "C"
WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
# Install mysql and calendar
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
@ -21,13 +22,16 @@ RUN \
docker-php-ext-install -j"$(nproc)" intl && \
# Install zip and gd
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
docker-php-ext-install -j"$(nproc)" zip gd
docker-php-ext-install -j"$(nproc)" zip gd && \
# Install gmp
apk add --no-cache gmp-dev && \
docker-php-ext-install -j"$(nproc)" gmp
# Install sqlsrv driver
RUN if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
pecl install pdo_sqlsrv && \
docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \
@ -35,7 +39,7 @@ RUN if [ $(uname -m) == "x86_64" ]; then \
fi
# Install swoole
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
@ -44,7 +48,7 @@ RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
# Install shlink
FROM base as builder
COPY . .
COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar
COPY --from=composer:2 /usr/bin/composer ./composer.phar
RUN apk add --no-cache git && \
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
php composer.phar clear-cache && \
@ -59,7 +63,7 @@ LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
COPY --from=builder /etc/shlink .
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
# Expose swoole port
# Expose default swoole port
EXPOSE 8080
# Expose params config dir, since the user is expected to provide custom config from there

View file

@ -1,6 +1,6 @@
![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/main/public/images/shlink-hero.png)
[![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink)
[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.com/shlinkio/shlink)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/)
[![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink)
@ -64,7 +64,7 @@ In order to run Shlink, you will need a built version of the project. There are
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching the generated dist file to it.
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.com/shlinkio/shlink), attaching the generated dist file to it.
### Configure

View file

@ -1,6 +1,7 @@
#!/usr/bin/env sh
export APP_ENV=test
export DB_DRIVER=mysql
export TEST_ENV=api
# Try to stop server just in case it hanged in last execution
vendor/bin/mezzio-swoole stop
@ -9,7 +10,7 @@ echo 'Starting server...'
vendor/bin/mezzio-swoole start -d
sleep 2
phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $*
testsExitCode=$?
vendor/bin/mezzio-swoole stop

View file

@ -16,7 +16,7 @@
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^1.0",
"cakephp/chronos": "^1.2",
"cakephp/chronos": "^2.0",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10",
@ -27,7 +27,6 @@
"guzzlehttp/guzzle": "^7.0",
"laminas/laminas-config": "^3.3",
"laminas/laminas-config-aggregator": "^1.1",
"laminas/laminas-dependency-plugin": "^1.0",
"laminas/laminas-diactoros": "^2.1.3",
"laminas/laminas-inputfilter": "^2.10",
"laminas/laminas-paginator": "^2.8",
@ -50,9 +49,10 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.2.0",
"shlinkio/shlink-common": "^3.3.0",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-importer": "^2.0.1",
"shlinkio/shlink-installer": "^5.1.0",
"shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1",
@ -66,13 +66,15 @@
"devster/ubench": "^2.0",
"dms/phpunit-arraysubset-asserts": "^0.2.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.16.1",
"phpstan/phpstan": "^0.12.18",
"phpunit/phpunit": "~9.0.1",
"infection/infection": "^0.20.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.52",
"phpunit/php-code-coverage": "^9.2",
"phpunit/phpunit": "^9.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.1.0",
"shlinkio/php-coding-standard": "~2.1.1",
"shlinkio/shlink-test-utils": "^1.5",
"symfony/var-dumper": "^5.0"
"symfony/var-dumper": "^5.1"
},
"autoload": {
"psr-4": {
@ -93,7 +95,10 @@
"module/Core/test",
"module/Core/test-db"
]
}
},
"files": [
"config/test/constants.php"
]
},
"scripts": {
"ci": [
@ -104,7 +109,7 @@
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=6",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config data/migrations --level=6",
"test": [
"@test:unit",
"@test:db",
@ -114,8 +119,8 @@
"@test:unit:ci",
"@test:db"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml",
"test:db": [
"@test:db:sqlite:ci",
"@test:db:mysql",
@ -123,14 +128,14 @@
"@test:db:postgres",
"@test:db:ms"
],
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml",
"test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
"test:unit:pretty": "@php vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage-unit-html",
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
"infect:ci:base": "@infect --skip-initial-tests",
"infect:ci": [
@ -166,6 +171,7 @@
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {
"sort-packages": true
"sort-packages": true,
"platform-check": false
}
}

View file

@ -2,7 +2,9 @@
declare(strict_types=1);
use GuzzleHttp\Client;
use Mezzio\Container;
use Psr\Http\Client\ClientInterface;
return [
@ -13,6 +15,10 @@ return [
],
],
'aliases' => [
ClientInterface::class => Client::class,
],
'lazy_services' => [
'proxies_target_dir' => 'data/proxies',
'proxies_namespace' => 'ShlinkProxy',

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
return [
'not_found_redirects' => [
'invalid_short_url' => null, // Formerly url_shortener.not_found_short_url.redirect_to
'invalid_short_url' => null,
'regular_404' => null,
'base_url' => null,
],

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\ConfigAggregator;
use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
@ -17,8 +18,10 @@ return (new ConfigAggregator\ConfigAggregator([
Mezzio\Plates\ConfigProvider::class,
Mezzio\Swoole\ConfigProvider::class,
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
Importer\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,

View file

@ -7,12 +7,28 @@ namespace Shlinkio\Shlink\TestUtils;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use function register_shutdown_function;
use function sprintf;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
/** @var ContainerInterface $container */
$container = require __DIR__ . '/../container.php';
$testHelper = $container->get(Helper\TestHelper::class);
$config = $container->get('config');
$em = $container->get(EntityManager::class);
$httpClient = $container->get('shlink_test_api_client');
// Start code coverage collecting on swoole process, and stop it when process shuts down
$httpClient->request('GET', sprintf('http://%s:%s/api-tests/start-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT));
register_shutdown_function(function () use ($httpClient): void {
$httpClient->request(
'GET',
sprintf('http://%s:%s/api-tests/stop-coverage', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
);
});
$testHelper->createTestDb();
ApiTest\ApiTestCase::setApiClient($container->get('shlink_test_api_client'));
ApiTest\ApiTestCase::setApiClient($httpClient);
ApiTest\ApiTestCase::setSeedFixturesCallback(fn () => $testHelper->seedFixtures($em, $config['data_fixtures'] ?? []));

View file

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink;
const SWOOLE_TESTING_HOST = '127.0.0.1';
const SWOOLE_TESTING_PORT = 9999;

View file

@ -6,15 +6,33 @@ namespace Shlinkio\Shlink;
use GuzzleHttp\Client;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Laminas\Stdlib\Glob;
use PDO;
use PHPUnit\Runner\Version;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use function Laminas\Stratigility\middleware;
use function Shlinkio\Shlink\Common\env;
use function sprintf;
use function sys_get_temp_dir;
$swooleTestingHost = '127.0.0.1';
$swooleTestingPort = 9999;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_HOST;
use const ShlinkioTest\Shlink\SWOOLE_TESTING_PORT;
$isApiTest = env('TEST_ENV') === 'api';
if ($isApiTest) {
$filter = new Filter();
foreach (Glob::glob(__DIR__ . '/../../module/*/src') as $item) {
$filter->includeDirectory($item);
}
$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter);
}
$buildDbConnection = function (): array {
$driver = env('DB_DRIVER', 'sqlite');
@ -78,8 +96,8 @@ return [
'mezzio-swoole' => [
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => $swooleTestingHost,
'port' => $swooleTestingPort,
'host' => SWOOLE_TESTING_HOST,
'port' => SWOOLE_TESTING_PORT,
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
@ -88,6 +106,35 @@ return [
],
],
'routes' => !$isApiTest ? [] : [
[
'name' => 'start_collecting_coverage',
'path' => '/api-tests/start-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
$coverage->start('API tests');
}
return new EmptyResponse();
}),
'allowed_methods' => ['GET'],
],
[
'name' => 'dump_coverage',
'path' => '/api-tests/stop-coverage',
'middleware' => middleware(static function () use (&$coverage) {
if ($coverage) {
$basePath = __DIR__ . '/../../build/coverage-api';
$coverage->stop();
(new PHP())->process($coverage, $basePath . '.cov');
(new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml');
}
return new EmptyResponse();
}),
'allowed_methods' => ['GET'],
],
],
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
@ -97,7 +144,7 @@ return [
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([
'base_uri' => sprintf('http://%s:%s/', $swooleTestingHost, $swooleTestingPort),
'base_uri' => sprintf('http://%s:%s/', SWOOLE_TESTING_HOST, SWOOLE_TESTING_PORT),
'http_errors' => false,
]),
],

View file

@ -1,9 +1,8 @@
FROM php:7.4.9-alpine3.12
FROM php:7.4.11-alpine3.12
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV XDEBUG_VERSION 2.9.0
RUN apk update
@ -31,6 +30,9 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache gmp-dev
RUN docker-php-ext-install gmp
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
@ -55,29 +57,17 @@ RUN rm /tmp/apcu_bc.tar.gz
RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
# Install xdebug
ADD https://pecl.php.net/get/xdebug-$XDEBUG_VERSION /tmp/xdebug.tar.gz
RUN mkdir -p /usr/src/php/ext/xdebug\
&& tar xf /tmp/xdebug.tar.gz -C /usr/src/php/ext/xdebug --strip-components=1
# configure and install
RUN docker-php-ext-configure xdebug\
&& docker-php-ext-install xdebug
# cleanup
RUN rm /tmp/xdebug.tar.gz
# Install sqlsrv driver
# Install pcov and sqlsrv driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv && \
docker-php-ext-enable pdo_sqlsrv && \
pecl install pdo_sqlsrv pcov && \
docker-php-ext-enable pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk
# Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php
RUN chmod +x composer.phar
RUN mv composer.phar /usr/local/bin/composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home

View file

@ -4,3 +4,5 @@ memory_limit=-1
log_errors_max_len=0
zend.assertions=1
assert.exception=1
pcov.enabled=1
pcov.directory=module

View file

@ -1,10 +1,10 @@
FROM php:7.4.9-alpine3.12
FROM php:7.4.11-alpine3.12
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0
ENV SWOOLE_VERSION 4.5.2
ENV SWOOLE_VERSION 4.5.5
RUN apk update
@ -32,6 +32,9 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
RUN apk add --no-cache gmp-dev
RUN docker-php-ext-install gmp
# Install APCu extension
ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
RUN mkdir -p /usr/src/php/ext/apcu\
@ -66,19 +69,17 @@ RUN docker-php-ext-configure inotify\
# cleanup
RUN rm /tmp/inotify.tar.gz
# Install swoole and mssql driver
# Install swoole, pcov and mssql driver
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
docker-php-ext-enable swoole pdo_sqlsrv && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \
docker-php-ext-enable swoole pdo_sqlsrv pcov && \
apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk
# Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php
RUN chmod +x composer.phar
RUN mv composer.phar /usr/local/bin/composer
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
# Make home directory writable by anyone
RUN chmod 777 /home

View file

@ -24,10 +24,10 @@ class Version20171021093246 extends AbstractMigration
return;
}
$shortUrls->addColumn('valid_since', Types::DATETIME, [
$shortUrls->addColumn('valid_since', Types::DATETIME_MUTABLE, [
'notnull' => false,
]);
$shortUrls->addColumn('valid_until', Types::DATETIME, [
$shortUrls->addColumn('valid_until', Types::DATETIME_MUTABLE, [
'notnull' => false,
]);
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20201023090929 extends AbstractMigration
{
private const IMPORT_SOURCE_COLUMN = 'import_source';
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN));
$shortUrls->addColumn(self::IMPORT_SOURCE_COLUMN, Types::STRING, [
'length' => 255,
'notnull' => false,
]);
$shortUrls->addColumn('import_original_short_code', Types::STRING, [
'length' => 255,
'notnull' => false,
]);
$shortUrls->addUniqueIndex(
[self::IMPORT_SOURCE_COLUMN, 'import_original_short_code', 'domain_id'],
'unique_imports',
);
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN));
$shortUrls->dropColumn(self::IMPORT_SOURCE_COLUMN);
$shortUrls->dropColumn('import_original_short_code');
$shortUrls->dropIndex('unique_imports');
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Cake\Chronos\Chronos;
use Doctrine\DBAL\Driver\Result;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20201102113208 extends AbstractMigration
{
private const API_KEY_COLUMN = 'author_api_key_id';
public function up(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf($shortUrls->hasColumn(self::API_KEY_COLUMN));
$shortUrls->addColumn(self::API_KEY_COLUMN, Types::BIGINT, [
'unsigned' => true,
'notnull' => false,
]);
$shortUrls->addForeignKeyConstraint('api_keys', [self::API_KEY_COLUMN], ['id'], [
'onDelete' => 'SET NULL',
'onUpdate' => 'RESTRICT',
], 'FK_' . self::API_KEY_COLUMN);
}
public function postUp(Schema $schema): void
{
// If there's only one API key and it's active, link all existing URLs with it
$qb = $this->connection->createQueryBuilder();
$qb->select('id')
->from('api_keys')
->where($qb->expr()->eq('enabled', ':enabled'))
->andWhere($qb->expr()->or(
$qb->expr()->isNull('expiration_date'),
$qb->expr()->gt('expiration_date', ':expiration'),
))
->setParameters([
'enabled' => true,
'expiration' => Chronos::now()->toDateTimeString(),
]);
/** @var Result $result */
$result = $qb->execute();
$id = $this->resolveOneApiKeyId($result);
if ($id === null) {
return;
}
$qb = $this->connection->createQueryBuilder();
$qb->update('short_urls')
->set(self::API_KEY_COLUMN, ':apiKeyId')
->setParameter('apiKeyId', $id)
->execute();
}
private function resolveOneApiKeyId(Result $result): ?string
{
$results = [];
while ($row = $result->fetchAssociative()) {
// As soon as we have to iterate more than once, then we cannot resolve a single API key
if (! empty($results)) {
return null;
}
$results[] = $row['id'] ?? null;
}
return $results[0] ?? null;
}
public function down(Schema $schema): void
{
$shortUrls = $schema->getTable('short_urls');
$this->skipIf(! $shortUrls->hasColumn(self::API_KEY_COLUMN));
$shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN);
$shortUrls->dropColumn(self::API_KEY_COLUMN);
}
}

View file

@ -176,15 +176,17 @@ This is the complete list of supported env vars:
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
* `REDIRECT_STATUS_CODE`: Either **301** or **302**. Used to determine if redirects from short to long URLs should be done with a 301 or 302 status. Defaults to 302.
* `REDIRECT_CACHE_LIFETIME`: Allows to set the amount of seconds that redirects should be cached when redirect status is 301. Default values is 30.
* `PORT`: Can be used to set the port in which shlink listens. Defaults to 8080 (Some cloud providers, like Google cloud or Heroku, expect to be able to customize exposed port by providing this env var).
An example using all env vars could look like this:
```bash
docker run \
--name shlink \
-p 8080:8080 \
-p 8080:8888 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e PORT=8888 \
-e DB_DRIVER=mysql \
-e DB_NAME=shlink \
-e DB_USER=root \
@ -257,7 +259,8 @@ The whole configuration should have this format, but it can be split into multip
"mercure_jwt_secret": "super_secret_key",
"anonymize_remote_addr": false,
"redirect_status_code": 301,
"redirect_cache_lifetime": 90
"redirect_cache_lifetime": 90,
"port": 8888
}
```

View file

@ -159,6 +159,7 @@ return [
'mezzio-swoole' => [
'swoole-http-server' => [
'port' => (int) env('PORT', 8080),
'options' => [
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),

View file

@ -31,7 +31,7 @@
{
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"required": false,
"schema": {
"type": "array",
@ -48,10 +48,14 @@
"schema": {
"type": "string",
"enum": [
"longUrl",
"shortCode",
"dateCreated",
"visits"
"longUrl-ASC",
"longUrl-DESC",
"shortCode-ASC",
"shortCode-DESC",
"dateCreated-ASC",
"dateCreated-DESC",
"visits-ASC",
"visits-DESC"
]
}
},
@ -247,6 +251,10 @@
"shortCodeLength": {
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
}
}
}

View file

@ -127,6 +127,10 @@
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number"
},
"validateUrl": {
"description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
"type": "boolean"
}
}
}

View file

@ -87,12 +87,13 @@
},
"post": {
"deprecated": true,
"operationId": "createTags",
"tags": [
"Tags"
],
"summary": "Create tags",
"description": "Provided a list of tags, creates all that do not yet exist",
"description": "Provided a list of tags, creates all that do not yet exist<br />This endpoint is deprecated, as tags are automatically created while creating a short URL",
"security": [
{
"ApiKey": []

View file

@ -0,0 +1,86 @@
{
"get": {
"operationId": "listDomains",
"tags": [
"Domains"
],
"summary": "List existing domains",
"description": "Returns the list of all domains ever used, with a flag that tells if they are the default domain",
"security": [
{
"ApiKey": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"responses": {
"200": {
"description": "The list of tags",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["domains"],
"properties": {
"domains": {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": ["domain", "isDefault"],
"properties": {
"domain": {
"type": "string"
},
"isDefault": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
},
"examples": {
"application/json": {
"domains": {
"data": [
{
"domain": "example.com",
"isDefault": true
},
{
"domain": "aaa.com",
"isDefault": false
},
{
"domain": "bbb.com",
"isDefault": false
}
]
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View file

@ -27,6 +27,19 @@
"maximum": 1000,
"default": 300
}
},
{
"name": "format",
"in": "query",
"description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
"required": false,
"schema": {
"type": "string",
"enum": [
"png",
"svg"
]
}
}
],
"responses": {
@ -38,6 +51,12 @@
"type": "string",
"format": "binary"
}
},
"image/svg+xml": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}

View file

@ -88,6 +88,10 @@
"$ref": "paths/v2_tags_{tag}_visits.json"
},
"/rest/v{version}/domains": {
"$ref": "paths/v2_domains.json"
},
"/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json"
},

View file

@ -25,6 +25,8 @@ return [
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class,
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
],

View file

@ -10,6 +10,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
@ -52,6 +53,8 @@ return [
Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class,
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
],
],
@ -84,6 +87,8 @@ return [
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Domain\ListDomainsCommand::class => [DomainService::class, 'config.url_shortener.domain.hostname'],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
SymfonyCli\Helper\ProcessHelper::class,

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function Functional\map;
class ListDomainsCommand extends Command
{
public const NAME = 'domain:list';
private DomainServiceInterface $domainService;
private string $defaultDomain;
public function __construct(DomainServiceInterface $domainService, string $defaultDomain)
{
parent::__construct();
$this->domainService = $domainService;
$this->defaultDomain = $defaultDomain;
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('List all domains that have been ever used for some short URL');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$regularDomains = $this->domainService->listDomainsWithout($this->defaultDomain);
ShlinkTable::fromOutput($output)->render(['Domain', 'Is default'], [
[$this->defaultDomain, 'Yes'],
...map($regularDomains, fn (Domain $domain) => [$domain->getAuthority(), 'No']),
]);
return ExitCodes::EXIT_SUCCESS;
}
}

View file

@ -21,7 +21,9 @@ use function array_map;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
use function method_exists;
use function sprintf;
use function strpos;
class GenerateShortUrlCommand extends Command
{
@ -94,6 +96,18 @@ class GenerateShortUrlCommand extends Command
'l',
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --customSlug was provided).',
)
->addOption(
'validate-url',
null,
InputOption::VALUE_NONE,
'Forces the long URL to be validated, regardless what is globally configured.',
)
->addOption(
'no-validate-url',
null,
InputOption::VALUE_NONE,
'Forces the long URL to not be validated, regardless what is globally configured.',
);
}
@ -125,9 +139,10 @@ class GenerateShortUrlCommand extends Command
$customSlug = $input->getOption('customSlug');
$maxVisits = $input->getOption('maxVisits');
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
$doValidateUrl = $this->doValidateUrl($input);
try {
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, ShortUrlMeta::fromRawData([
$shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
@ -135,6 +150,7 @@ class GenerateShortUrlCommand extends Command
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl,
]));
$io->writeln([
@ -147,4 +163,18 @@ class GenerateShortUrlCommand extends Command
return ExitCodes::EXIT_FAILURE;
}
}
private function doValidateUrl(InputInterface $input): ?bool
{
$rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
if (strpos($rawInput, '--no-validate-url') !== false) {
return false;
}
if (strpos($rawInput, '--validate-url') !== false) {
return true;
}
return null;
}
}

View file

@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/** @deprecated */
class CreateTagCommand extends Command
{
public const NAME = 'tag:create';
@ -28,7 +29,7 @@ class CreateTagCommand extends Command
{
$this
->setName(self::NAME)
->setDescription('Creates one or more tags.')
->setDescription('[Deprecated] Creates one or more tags.')
->addOption(
'name',
't',

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class DisableKeyCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
@ -37,7 +40,7 @@ class DisableKeyCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('API key "abcd1234" properly disabled', $output);
self::assertStringContainsString('API key "abcd1234" properly disabled', $output);
}
/** @test */
@ -52,7 +55,7 @@ class DisableKeyCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString($expectedMessage, $output);
self::assertStringContainsString($expectedMessage, $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View file

@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -16,6 +17,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
@ -36,7 +39,7 @@ class GenerateKeyCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Generated API key: ', $output);
self::assertStringContainsString('Generated API key: ', $output);
$create->shouldHaveBeenCalledOnce();
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class ListKeysCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $apiKeyService;
@ -38,11 +41,11 @@ class ListKeysCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Key', $output);
$this->assertStringContainsString('Is enabled', $output);
$this->assertStringContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
self::assertStringContainsString('Key', $output);
self::assertStringContainsString('Is enabled', $output);
self::assertStringContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
}
/** @test */
@ -58,10 +61,10 @@ class ListKeysCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Key', $output);
$this->assertStringNotContainsString('Is enabled', $output);
$this->assertStringNotContainsString(' +++ ', $output);
$this->assertStringNotContainsString(' --- ', $output);
$this->assertStringContainsString('Expiration date', $output);
self::assertStringContainsString('Key', $output);
self::assertStringNotContainsString('Is enabled', $output);
self::assertStringNotContainsString(' +++ ', $output);
self::assertStringNotContainsString(' --- ', $output);
self::assertStringContainsString('Expiration date', $output);
}
}

View file

@ -9,6 +9,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Symfony\Component\Console\Application;
@ -22,10 +23,11 @@ use Symfony\Component\Process\Process;
class CreateDatabaseCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $processHelper;
private ObjectProphecy $regularConn;
private ObjectProphecy $noDbNameConn;
private ObjectProphecy $schemaManager;
private ObjectProphecy $databasePlatform;
@ -48,15 +50,15 @@ class CreateDatabaseCommandTest extends TestCase
$this->regularConn = $this->prophesize(Connection::class);
$this->regularConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal());
$this->noDbNameConn = $this->prophesize(Connection::class);
$this->noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$noDbNameConn = $this->prophesize(Connection::class);
$noDbNameConn->getSchemaManager()->willReturn($this->schemaManager->reveal());
$command = new CreateDatabaseCommand(
$locker->reveal(),
$this->processHelper->reveal(),
$phpExecutableFinder->reveal(),
$this->regularConn->reveal(),
$this->noDbNameConn->reveal(),
$noDbNameConn->reveal(),
);
$app = new Application();
$app->add($command);
@ -77,7 +79,7 @@ class CreateDatabaseCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
self::assertStringContainsString('Database already exists. Run "db:migrate" command', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();
@ -121,8 +123,8 @@ class CreateDatabaseCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Creating database tables...', $output);
$this->assertStringContainsString('Database properly created!', $output);
self::assertStringContainsString('Creating database tables...', $output);
self::assertStringContainsString('Database properly created!', $output);
$getDatabase->shouldHaveBeenCalledOnce();
$listDatabases->shouldHaveBeenCalledOnce();
$createDatabase->shouldNotHaveBeenCalled();

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Db;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Symfony\Component\Console\Application;
@ -19,6 +20,8 @@ use Symfony\Component\Process\Process;
class MigrateDatabaseCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $processHelper;
@ -60,8 +63,8 @@ class MigrateDatabaseCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Migrating database...', $output);
$this->assertStringContainsString('Database properly migrated!', $output);
self::assertStringContainsString('Migrating database...', $output);
self::assertStringContainsString('Database properly migrated!', $output);
$runCommand->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Domain;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Domain\ListDomainsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class ListDomainsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $domainService;
public function setUp(): void
{
$this->domainService = $this->prophesize(DomainServiceInterface::class);
$command = new ListDomainsCommand($this->domainService->reveal(), 'foo.com');
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/** @test */
public function allDomainsAreProperlyPrinted(): void
{
$expectedOutput = <<<OUTPUT
+---------+------------+
| Domain | Is default |
+---------+------------+
| foo.com | Yes |
| bar.com | No |
| baz.com | No |
+---------+------------+
OUTPUT;
$listDomains = $this->domainService->listDomainsWithout('foo.com')->willReturn([
new Domain('bar.com'),
new Domain('baz.com'),
]);
$this->commandTester->execute([]);
self::assertEquals($expectedOutput, $this->commandTester->getDisplay());
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$listDomains->shouldHaveBeenCalledOnce();
}
}

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception;
@ -21,6 +22,8 @@ use const PHP_EOL;
class DeleteShortUrlCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $service;
@ -47,7 +50,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
self::assertStringContainsString(
sprintf('Short URL with short code "%s" successfully deleted.', $shortCode),
$output,
);
@ -66,7 +69,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
@ -95,11 +98,11 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf(
self::assertStringContainsString(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
$shortCode,
), $output);
$this->assertStringContainsString($expectedMessage, $output);
self::assertStringContainsString($expectedMessage, $output);
$deleteByShortCode->shouldHaveBeenCalledTimes($expectedDeleteCalls);
}
@ -122,11 +125,11 @@ class DeleteShortUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf(
self::assertStringContainsString(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
$shortCode,
), $output);
$this->assertStringContainsString('Short URL was not deleted.', $output);
self::assertStringContainsString('Short URL was not deleted.', $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
}

View file

@ -7,18 +7,22 @@ namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GenerateShortUrlCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateShortUrlCommandTest extends TestCase
{
use ProphecyTrait;
private const DOMAIN_CONFIG = [
'schema' => 'http',
'hostname' => 'foo.com',
@ -40,7 +44,7 @@ class GenerateShortUrlCommandTest extends TestCase
public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
{
$shortUrl = new ShortUrl('');
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn($shortUrl);
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
$this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar',
@ -48,8 +52,8 @@ class GenerateShortUrlCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
@ -57,28 +61,28 @@ class GenerateShortUrlCommandTest extends TestCase
public function exceptionWhileParsingLongUrlOutputsError(): void
{
$url = 'http://domain.com/invalid';
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
$this->urlShortener->shorten(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
->shouldBeCalledOnce();
$this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
}
/** @test */
public function providingNonUniqueSlugOutputsError(): void
{
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
$urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willThrow(
NonUniqueSlugException::fromSlug('my-slug'),
);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
$output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Provided slug "my-slug" is already in use', $output);
self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
self::assertStringContainsString('Provided slug "my-slug" is already in use', $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
@ -86,7 +90,7 @@ class GenerateShortUrlCommandTest extends TestCase
public function properlyProcessesProvidedTags(): void
{
$shortUrl = new ShortUrl('');
$urlToShortCode = $this->urlShortener->urlToShortCode(
$urlToShortCode = $this->urlShortener->shorten(
Argument::type('string'),
Argument::that(function (array $tags) {
Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
@ -101,8 +105,38 @@ class GenerateShortUrlCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
$this->assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
/**
* @test
* @dataProvider provideFlags
*/
public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
{
$shortUrl = new ShortUrl('');
$urlToShortCode = $this->urlShortener->shorten(
Argument::type('string'),
Argument::type('array'),
Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) {
Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
return $meta;
}),
)->willReturn($shortUrl);
$options['longUrl'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options);
$urlToShortCode->shouldHaveBeenCalledOnce();
}
public function provideFlags(): iterable
{
yield 'no flags' => [[], null];
yield 'no-validate-url only' => [['--no-validate-url' => true], false];
yield 'validate-url' => [['--validate-url' => true], true];
yield 'both flags' => [['--validate-url' => true, '--no-validate-url' => true], false];
}
}

View file

@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter;
use Laminas\Paginator\Paginator;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
@ -27,6 +28,8 @@ use function sprintf;
class GetVisitsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitsTracker;
@ -88,7 +91,7 @@ class GetVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$info->shouldHaveBeenCalledOnce();
$this->assertStringContainsString(
self::assertStringContainsString(
sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
$output,
);
@ -108,8 +111,8 @@ class GetVisitsCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('Spain', $output);
$this->assertStringContainsString('bar', $output);
self::assertStringContainsString('foo', $output);
self::assertStringContainsString('Spain', $output);
self::assertStringContainsString('bar', $output);
}
}

View file

@ -9,6 +9,7 @@ use Laminas\Paginator\Adapter\ArrayAdapter;
use Laminas\Paginator\Paginator;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -21,6 +22,8 @@ use function explode;
class ListShortUrlsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $shortUrlService;
@ -50,9 +53,9 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringContainsString('Continue with page 3?', $output);
$this->assertStringContainsString('Continue with page 4?', $output);
self::assertStringContainsString('Continue with page 2?', $output);
self::assertStringContainsString('Continue with page 3?', $output);
self::assertStringContainsString('Continue with page 4?', $output);
}
/** @test */
@ -72,13 +75,13 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('url_1', $output);
$this->assertStringContainsString('url_9', $output);
$this->assertStringNotContainsString('url_10', $output);
$this->assertStringNotContainsString('url_20', $output);
$this->assertStringNotContainsString('url_30', $output);
$this->assertStringContainsString('Continue with page 2?', $output);
$this->assertStringNotContainsString('Continue with page 3?', $output);
self::assertStringContainsString('url_1', $output);
self::assertStringContainsString('url_9', $output);
self::assertStringNotContainsString('url_10', $output);
self::assertStringNotContainsString('url_20', $output);
self::assertStringNotContainsString('url_30', $output);
self::assertStringContainsString('Continue with page 2?', $output);
self::assertStringNotContainsString('Continue with page 3?', $output);
}
/** @test */
@ -103,7 +106,7 @@ class ListShortUrlsCommandTest extends TestCase
$this->commandTester->setInputs(['y']);
$this->commandTester->execute(['--showTags' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tags', $output);
self::assertStringContainsString('Tags', $output);
}
/**

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -20,6 +21,8 @@ use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $urlResolver;
@ -44,7 +47,7 @@ class ResolveUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
/** @test */
@ -59,6 +62,6 @@ class ResolveUrlCommandTest extends TestCase
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}
}

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
@ -14,6 +15,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class CreateTagCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
@ -34,7 +37,7 @@ class CreateTagCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('You have to provide at least one tag name', $output);
self::assertStringContainsString('You have to provide at least one tag name', $output);
}
/** @test */
@ -48,7 +51,7 @@ class CreateTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tags properly created', $output);
self::assertStringContainsString('Tags properly created', $output);
$createTags->shouldHaveBeenCalled();
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
@ -13,6 +14,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class DeleteTagsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
@ -33,7 +36,7 @@ class DeleteTagsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('You have to provide at least one tag name', $output);
self::assertStringContainsString('You have to provide at least one tag name', $output);
}
/** @test */
@ -48,7 +51,7 @@ class DeleteTagsCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tags properly deleted', $output);
self::assertStringContainsString('Tags properly deleted', $output);
$deleteTags->shouldHaveBeenCalled();
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class ListTagsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
@ -37,7 +40,7 @@ class ListTagsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('No tags found', $output);
self::assertStringContainsString('No tags found', $output);
$tagsInfo->shouldHaveBeenCalled();
}
@ -52,12 +55,12 @@ class ListTagsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('| foo', $output);
$this->assertStringContainsString('| bar', $output);
$this->assertStringContainsString('| 10 ', $output);
$this->assertStringContainsString('| 2 ', $output);
$this->assertStringContainsString('| 7 ', $output);
$this->assertStringContainsString('| 32 ', $output);
self::assertStringContainsString('| foo', $output);
self::assertStringContainsString('| bar', $output);
self::assertStringContainsString('| 10 ', $output);
self::assertStringContainsString('| 2 ', $output);
self::assertStringContainsString('| 7 ', $output);
self::assertStringContainsString('| 32 ', $output);
$tagsInfo->shouldHaveBeenCalled();
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
@ -15,6 +16,8 @@ use Symfony\Component\Console\Tester\CommandTester;
class RenameTagCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $tagService;
@ -42,7 +45,7 @@ class RenameTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tag with name "foo" could not be found', $output);
self::assertStringContainsString('Tag with name "foo" could not be found', $output);
$renameTag->shouldHaveBeenCalled();
}
@ -59,7 +62,7 @@ class RenameTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Tag properly renamed', $output);
self::assertStringContainsString('Tag properly renamed', $output);
$renameTag->shouldHaveBeenCalled();
}
}

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\LocateVisitsCommand;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
@ -32,10 +33,11 @@ use const PHP_EOL;
class LocateVisitsCommandTest extends TestCase
{
use ProphecyTrait;
private CommandTester $commandTester;
private ObjectProphecy $visitService;
private ObjectProphecy $ipResolver;
private ObjectProphecy $locker;
private ObjectProphecy $lock;
private ObjectProphecy $dbUpdater;
@ -45,17 +47,17 @@ class LocateVisitsCommandTest extends TestCase
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->locker = $this->prophesize(Lock\LockFactory::class);
$locker = $this->prophesize(Lock\LockFactory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(false)->willReturn(true);
$this->lock->release()->will(function (): void {
});
$this->locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
$locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
$command = new LocateVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
$this->locker->reveal(),
$locker->reveal(),
$this->dbUpdater->reveal(),
);
$app = new Application();
@ -92,11 +94,11 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute($args);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
self::assertStringContainsString('Processing IP 1.2.3.0', $output);
if ($expectWarningPrint) {
$this->assertStringContainsString('Continue at your own risk', $output);
self::assertStringContainsString('Continue at your own', $output);
} else {
$this->assertStringNotContainsString('Continue at your own risk', $output);
self::assertStringNotContainsString('Continue at your own', $output);
}
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
@ -132,11 +134,11 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString($message, $output);
self::assertStringContainsString($message, $output);
if (empty($address)) {
$this->assertStringNotContainsString('Processing IP', $output);
self::assertStringNotContainsString('Processing IP', $output);
} else {
$this->assertStringContainsString('Processing IP', $output);
self::assertStringContainsString('Processing IP', $output);
}
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldNotHaveBeenCalled();
@ -164,7 +166,7 @@ class LocateVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('An error occurred while locating IP. Skipped', $output);
self::assertStringContainsString('An error occurred while locating IP. Skipped', $output);
$locateVisits->shouldHaveBeenCalledOnce();
$resolveIpLocation->shouldHaveBeenCalledOnce();
}
@ -192,7 +194,7 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute([], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
self::assertStringContainsString(
sprintf('Command "%s" is already in progress. Skipping.', LocateVisitsCommand::NAME),
$output,
);
@ -222,11 +224,11 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(
self::assertStringContainsString(
sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'),
$output,
);
$this->assertStringContainsString($expectedMessage, $output);
self::assertStringContainsString($expectedMessage, $output);
$locateVisits->shouldHaveBeenCalledTimes((int) $olderDbExists);
$checkDbUpdate->shouldHaveBeenCalledOnce();
}
@ -243,7 +245,7 @@ class LocateVisitsCommandTest extends TestCase
$this->commandTester->execute(['--all' => true]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('The --all flag has no effect on its own', $output);
self::assertStringContainsString('The --all flag has no effect on its own', $output);
}
/**

View file

@ -17,11 +17,11 @@ class ConfigProviderTest extends TestCase
}
/** @test */
public function confiIsProperlyReturned(): void
public function configIsProperlyReturned(): void
{
$config = ($this->configProvider)();
$this->assertArrayHasKey('cli', $config);
$this->assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('cli', $config);
self::assertArrayHasKey('dependencies', $config);
}
}

View file

@ -20,13 +20,13 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
$e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals(
self::assertEquals($olderDbExists, $e->olderDbExists());
self::assertEquals(
'An error occurred while updating geolocation database, and an older version could not be found',
$e->getMessage(),
);
$this->assertEquals(0, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
self::assertEquals(0, $e->getCode());
self::assertEquals($prev, $e->getPrevious());
}
public function provideCreateArgs(): iterable

View file

@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Factory;
use Laminas\ServiceManager\ServiceManager;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
@ -15,6 +16,8 @@ use Symfony\Component\Console\Command\Command;
class ApplicationFactoryTest extends TestCase
{
use ProphecyTrait;
private ApplicationFactory $factory;
public function setUp(): void
@ -37,9 +40,9 @@ class ApplicationFactoryTest extends TestCase
$instance = ($this->factory)($sm);
$this->assertTrue($instance->has('foo'));
$this->assertTrue($instance->has('bar'));
$this->assertFalse($instance->has('baz'));
self::assertTrue($instance->has('foo'));
self::assertTrue($instance->has('bar'));
self::assertFalse($instance->has('baz'));
}
private function createServiceManager(array $config = []): ServiceManager

View file

@ -9,6 +9,7 @@ use GeoIp2\Database\Reader;
use MaxMind\Db\Reader\Metadata;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
@ -22,35 +23,35 @@ use function range;
class GeolocationDbUpdaterTest extends TestCase
{
use ProphecyTrait;
private GeolocationDbUpdater $geolocationDbUpdater;
private ObjectProphecy $dbUpdater;
private ObjectProphecy $geoLiteDbReader;
private ObjectProphecy $locker;
private ObjectProphecy $lock;
public function setUp(): void
{
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
$this->geoLiteDbReader = $this->prophesize(Reader::class);
$this->locker = $this->prophesize(Lock\LockFactory::class);
$this->lock = $this->prophesize(Lock\LockInterface::class);
$this->lock->acquire(true)->willReturn(true);
$this->lock->release()->will(function (): void {
$locker = $this->prophesize(Lock\LockFactory::class);
$lock = $this->prophesize(Lock\LockInterface::class);
$lock->acquire(true)->willReturn(true);
$lock->release()->will(function (): void {
});
$this->locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal());
$locker->createLock(Argument::type('string'))->willReturn($lock->reveal());
$this->geolocationDbUpdater = new GeolocationDbUpdater(
$this->dbUpdater->reveal(),
$this->geoLiteDbReader->reveal(),
$this->locker->reveal(),
$locker->reveal(),
);
}
/** @test */
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{
$mustBeUpdated = fn () => $this->assertTrue(true);
$mustBeUpdated = fn () => self::assertTrue(true);
$prev = new RuntimeException('');
$fileExists = $this->dbUpdater->databaseFileExists()->willReturn(false);
@ -59,12 +60,12 @@ class GeolocationDbUpdaterTest extends TestCase
try {
$this->geolocationDbUpdater->checkDbUpdate($mustBeUpdated);
$this->assertTrue(false); // If this is reached, the test will fail
self::assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertFalse($e->olderDbExists());
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertFalse($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();
@ -95,12 +96,12 @@ class GeolocationDbUpdaterTest extends TestCase
try {
$this->geolocationDbUpdater->checkDbUpdate();
$this->assertTrue(false); // If this is reached, the test will fail
self::assertTrue(false); // If this is reached, the test will fail
} catch (Throwable $e) {
/** @var GeolocationDbUpdateFailedException $e */
$this->assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
$this->assertSame($prev, $e->getPrevious());
$this->assertTrue($e->olderDbExists());
self::assertInstanceOf(GeolocationDbUpdateFailedException::class, $e);
self::assertSame($prev, $e->getPrevious());
self::assertTrue($e->olderDbExists());
}
$fileExists->shouldHaveBeenCalledOnce();

View file

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
@ -15,6 +16,8 @@ use Symfony\Component\Console\Output\OutputInterface;
class ShlinkTableTest extends TestCase
{
use ProphecyTrait;
private ShlinkTable $shlinkTable;
private ObjectProphecy $baseTable;
@ -60,6 +63,6 @@ class ShlinkTableTest extends TestCase
$baseTable = $ref->getProperty('baseTable');
$baseTable->setAccessible(true);
$this->assertInstanceOf(Table::class, $baseTable->getValue($instance));
self::assertInstanceOf(Table::class, $baseTable->getValue($instance));
}
}

View file

@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Mezzio\Template\TemplateRendererInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\Domain\Resolver;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
return [
@ -31,16 +31,25 @@ return [
Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
Domain\DomainService::class => ConfigAbstractFactory::class,
Util\UrlValidator::class => ConfigAbstractFactory::class,
Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
],
'aliases' => [
ImportedLinksProcessorInterface::class => Importer\ImportedLinksProcessor::class,
],
],
@ -53,7 +62,12 @@ return [
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
Service\UrlShortener::class => [
Util\UrlValidator::class,
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeHelper::class,
],
Service\VisitsTracker::class => [
'em',
EventDispatcherInterface::class,
@ -69,8 +83,11 @@ return [
Service\ShortUrl\ShortUrlResolver::class,
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
Service\ShortUrl\ShortCodeHelper::class => ['em'],
Domain\DomainService::class => ['em'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
@ -91,9 +108,16 @@ return [
'Logger_Shlink',
],
Resolver\PersistenceDomainResolver::class => ['em'],
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
Importer\ImportedLinksProcessor::class => [
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
Service\ShortUrl\ShortCodeHelper::class,
Util\DoctrineBatchHelper::class,
],
],
];

View file

@ -11,7 +11,8 @@ use Doctrine\ORM\Mapping\ClassMetadata;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable(determineTableName('domains', $emConfig));
$builder->setTable(determineTableName('domains', $emConfig))
->setCustomRepositoryClass(Domain\Repository\DomainRepository::class);
$builder->createField('id', Types::BIGINT)
->columnName('id')

View file

@ -8,6 +8,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
return static function (ClassMetadata $metadata, array $emConfig): void {
$builder = new ClassMetadataBuilder($metadata);
@ -51,6 +52,16 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->nullable()
->build();
$builder->createField('importSource', Types::STRING)
->columnName('import_source')
->nullable()
->build();
$builder->createField('importOriginalShortCode', Types::STRING)
->columnName('import_original_short_code')
->nullable()
->build();
$builder->createOneToMany('visits', Entity\Visit::class)
->mappedBy('shortUrl')
->fetchExtraLazy()
@ -68,5 +79,9 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->cascadePersist()
->build();
$builder->createManyToOne('authorApiKey', ApiKey::class)
->addJoinColumn('author_api_key_id', 'id', true, false, 'SET NULL')
->build();
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
};

View file

@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
use function sprintf;
@ -62,3 +63,15 @@ function determineTableName(string $tableName, array $emConfig = []): string
return sprintf('%s.%s', $schema, $tableName);
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
}
function getOptionalBoolFromInputFilter(InputFilter $inputFilter, string $fieldName): ?bool
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (bool) $value : null;
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Action;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\SvgWriter;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
@ -51,6 +52,11 @@ class QrCodeAction implements MiddlewareInterface
$qrCode->setSize($this->getSizeParam($request));
$qrCode->setMargin(0);
$format = $request->getQueryParams()['format'] ?? 'png';
if ($format === 'svg') {
$qrCode->setWriter(new SvgWriter());
}
return new QrCodeResponse($qrCode);
}

View file

@ -40,6 +40,7 @@ class SimplifiedConfigParser
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
'redirect_status_code' => ['url_shortener', 'redirect_status_code'],
'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'],
'port' => ['mezzio-swoole', 'swoole-http-server', 'port'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
class DomainService implements DomainServiceInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @return Domain[]
*/
public function listDomainsWithout(?string $excludeDomain = null): array
{
/** @var DomainRepositoryInterface $repo */
$repo = $this->em->getRepository(Domain::class);
return $repo->findDomainsWithout($excludeDomain);
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain;
use Shlinkio\Shlink\Core\Entity\Domain;
interface DomainServiceInterface
{
/**
* @return Domain[]
*/
public function listDomainsWithout(?string $excludeDomain = null): array;
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\ORM\EntityRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
class DomainRepository extends EntityRepository implements DomainRepositoryInterface
{
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority = null): array
{
$qb = $this->createQueryBuilder('d')->orderBy('d.authority', 'ASC');
if ($excludedAuthority !== null) {
$qb->where($qb->expr()->neq('d.authority', ':excludedAuthority'))
->setParameter('excludedAuthority', $excludedAuthority);
}
return $qb->getQuery()->getResult();
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Repository;
use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
interface DomainRepositoryInterface extends ObjectRepository
{
/**
* @return Domain[]
*/
public function findDomainsWithout(?string $excludedAuthority = null): array;
}

View file

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
interface DomainResolverInterface
{
public function resolveDomain(?string $domain): ?Domain;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
class SimpleDomainResolver implements DomainResolverInterface
{
public function resolveDomain(?string $domain): ?Domain
{
return $domain !== null ? new Domain($domain) : null;
}
}

View file

@ -9,16 +9,16 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Laminas\Diactoros\Uri;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use function array_reduce;
use function count;
use function Functional\contains;
use function Functional\invoke;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
class ShortUrl extends AbstractEntity
@ -36,13 +36,17 @@ class ShortUrl extends AbstractEntity
private ?Domain $domain = null;
private bool $customSlugWasProvided;
private int $shortCodeLength;
private ?string $importSource = null;
private ?string $importOriginalShortCode = null;
private ?ApiKey $authorApiKey = null;
public function __construct(
string $longUrl,
?ShortUrlMeta $meta = null,
?DomainResolverInterface $domainResolver = null
?ShortUrlRelationResolverInterface $relationResolver = null
) {
$meta = $meta ?? ShortUrlMeta::createEmpty();
$relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
$this->longUrl = $longUrl;
$this->dateCreated = Chronos::now();
@ -54,7 +58,29 @@ class ShortUrl extends AbstractEntity
$this->customSlugWasProvided = $meta->hasCustomSlug();
$this->shortCodeLength = $meta->getShortCodeLength();
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
$this->domain = $relationResolver->resolveDomain($meta->getDomain());
$this->authorApiKey = $relationResolver->resolveApiKey($meta->getApiKey());
}
public static function fromImport(
ImportedShlinkUrl $url,
bool $importShortCode,
?ShortUrlRelationResolverInterface $relationResolver = null
): self {
$meta = [
ShortUrlMetaInputFilter::DOMAIN => $url->domain(),
ShortUrlMetaInputFilter::VALIDATE_URL => false,
];
if ($importShortCode) {
$meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode();
}
$instance = new self($url->longUrl(), ShortUrlMeta::fromRawData($meta), $relationResolver);
$instance->importSource = $url->source();
$instance->importOriginalShortCode = $url->shortCode();
$instance->dateCreated = Chronos::instance($url->createdAt());
return $instance;
}
public function getLongUrl(): string
@ -113,10 +139,10 @@ class ShortUrl extends AbstractEntity
/**
* @throws ShortCodeCannotBeRegeneratedException
*/
public function regenerateShortCode(): self
public function regenerateShortCode(): void
{
// In ShortUrls where a custom slug was provided, do nothing
if ($this->customSlugWasProvided) {
// In ShortUrls where a custom slug was provided, throw error, unless it is an imported one
if ($this->customSlugWasProvided && $this->importSource === null) {
throw ShortCodeCannotBeRegeneratedException::forShortUrlWithCustomSlug();
}
@ -126,7 +152,6 @@ class ShortUrl extends AbstractEntity
}
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
return $this;
}
public function getValidSince(): ?Chronos
@ -195,27 +220,4 @@ class ShortUrl extends AbstractEntity
return $this->domain->getAuthority();
}
public function matchesCriteria(ShortUrlMeta $meta, array $tags): bool
{
if ($meta->hasMaxVisits() && $meta->getMaxVisits() !== $this->maxVisits) {
return false;
}
if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) {
return false;
}
if ($meta->hasValidSince() && ($this->validSince === null || ! $meta->getValidSince()->eq($this->validSince))) {
return false;
}
if ($meta->hasValidUntil() && ($this->validUntil === null || ! $meta->getValidUntil()->eq($this->validUntil))) {
return false;
}
$shortUrlTags = invoke($this->getTags(), '__toString');
return count($shortUrlTags) === count($tags) && array_reduce(
$tags,
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
true,
);
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Importer;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Symfony\Component\Console\Style\StyleInterface;
use function sprintf;
class ImportedLinksProcessor implements ImportedLinksProcessorInterface
{
use TagManagerTrait;
private EntityManagerInterface $em;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
private DoctrineBatchHelperInterface $batchHelper;
public function __construct(
EntityManagerInterface $em,
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper,
DoctrineBatchHelperInterface $batchHelper
) {
$this->em = $em;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
$this->batchHelper = $batchHelper;
}
/**
* @param iterable|ImportedShlinkUrl[] $shlinkUrls
*/
public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void
{
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$importShortCodes = $params['import_short_codes'];
$iterable = $this->batchHelper->wrapIterable($shlinkUrls, 100);
/** @var ImportedShlinkUrl $url */
foreach ($iterable as $url) {
$longUrl = $url->longUrl();
// Skip already imported URLs
if ($shortUrlRepo->importedUrlExists($url)) {
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
continue;
}
$shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags()));
if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
continue;
}
$this->em->persist($shortUrl);
$io->text(sprintf('%s: <info>Imported</info>', $longUrl));
}
}
private function handleShortCodeUniqueness(
ImportedShlinkUrl $url,
ShortUrl $shortUrl,
StyleInterface $io,
bool $importShortCodes
): bool {
if ($this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, $importShortCodes)) {
return true;
}
$longUrl = $url->longUrl();
$action = $io->choice(sprintf(
'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate a new '
. 'one or skip it?',
$longUrl,
$url->shortCode(),
), ['Generate new short-code', 'Skip'], 1);
if ($action === 'Skip') {
$io->text(sprintf('%s: <comment>Skipped</comment>', $longUrl));
return false;
}
return $this->shortCodeHelper->ensureShortCodeUniqueness($shortUrl, false);
}
}

View file

@ -9,6 +9,8 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function array_key_exists;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlEdit
@ -21,6 +23,7 @@ final class ShortUrlEdit
private ?Chronos $validUntil = null;
private bool $maxVisitsPropWasProvided = false;
private ?int $maxVisits = null;
private ?bool $validateUrl = null;
// Enforce named constructors
private function __construct()
@ -55,13 +58,8 @@ final class ShortUrlEdit
$this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
}
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
}
public function longUrl(): ?string
@ -103,4 +101,9 @@ final class ShortUrlEdit
{
return $this->maxVisitsPropWasProvided;
}
public function doValidateUrl(): ?bool
{
return $this->validateUrl;
}
}

View file

@ -8,6 +8,8 @@ use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter;
use function Shlinkio\Shlink\Core\parseDateField;
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
@ -21,6 +23,8 @@ final class ShortUrlMeta
private ?bool $findIfExists = null;
private ?string $domain = null;
private int $shortCodeLength = 5;
private ?bool $validateUrl = null;
private ?string $apiKey = null;
// Enforce named constructors
private function __construct()
@ -55,19 +59,15 @@ final class ShortUrlMeta
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
$this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
$this->shortCodeLength = getOptionalIntFromInputFilter(
$inputFilter,
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
) ?? DEFAULT_SHORT_CODES_LENGTH;
}
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);
return $value !== null ? (int) $value : null;
$this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY);
}
public function getValidSince(): ?Chronos
@ -129,4 +129,14 @@ final class ShortUrlMeta
{
return $this->shortCodeLength;
}
public function doValidateUrl(): ?bool
{
return $this->validateUrl;
}
public function getApiKey(): ?string
{
return $this->apiKey;
}
}

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use function explode;
use function is_array;
use function is_string;
use function key;
@ -40,15 +41,22 @@ final class ShortUrlsOrdering
return;
}
// FIXME Providing the ordering as array is considered deprecated. To be removed in v3.0.0
$isArray = is_array($orderBy);
if (! $isArray && $orderBy !== null && ! is_string($orderBy)) {
if (! $isArray && ! is_string($orderBy)) {
throw ValidationException::fromArray([
'orderBy' => '"Order by" must be an array, string or null',
]);
}
$this->orderField = $isArray ? key($orderBy) : $orderBy;
$this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION;
if (! $isArray) {
$parts = explode('-', $orderBy);
$this->orderField = $parts[0];
$this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION;
} else {
$this->orderField = key($orderBy);
$this->orderDirection = $orderBy[$this->orderField];
}
}
public function orderField(): ?string

View file

@ -5,13 +5,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function array_column;
use function array_key_exists;
use function count;
use function Functional\contains;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
@ -186,6 +190,85 @@ DQL;
->setParameter('slug', $slug)
->setMaxResults(1);
$this->whereDomainIs($qb, $domain);
return $qb;
}
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('s')
->from(ShortUrl::class, 's')
->where($qb->expr()->eq('s.longUrl', ':longUrl'))
->setParameter('longUrl', $url)
->setMaxResults(1)
->orderBy('s.id');
if ($meta->hasCustomSlug()) {
$qb->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
->setParameter('slug', $meta->getCustomSlug());
}
if ($meta->hasMaxVisits()) {
$qb->andWhere($qb->expr()->eq('s.maxVisits', ':maxVisits'))
->setParameter('maxVisits', $meta->getMaxVisits());
}
if ($meta->hasValidSince()) {
$qb->andWhere($qb->expr()->eq('s.validSince', ':validSince'))
->setParameter('validSince', $meta->getValidSince());
}
if ($meta->hasValidUntil()) {
$qb->andWhere($qb->expr()->eq('s.validUntil', ':validUntil'))
->setParameter('validUntil', $meta->getValidUntil());
}
if ($meta->hasDomain()) {
$qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $meta->getDomain());
}
$tagsAmount = count($tags);
if ($tagsAmount === 0) {
return $qb->getQuery()->getOneOrNullResult();
}
foreach ($tags as $index => $tag) {
$alias = 't_' . $index;
$qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
->setParameter('tag' . $index, $tag);
}
// If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we
// can discard those that also have more tags, making sure only those fully matching are included.
$qb->join('s.tags', 't')
->groupBy('s')
->having($qb->expr()->eq('COUNT(t.id)', ':tagsAmount'))
->setParameter('tagsAmount', $tagsAmount);
return $qb->getQuery()->getOneOrNullResult();
}
public function importedUrlExists(ImportedShlinkUrl $url): bool
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(DISTINCT s.id)')
->from(ShortUrl::class, 's')
->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode'))
->setParameter('shortCode', $url->shortCode())
->andWhere($qb->expr()->eq('s.importSource', ':importSource'))
->setParameter('importSource', $url->source())
->setMaxResults(1);
$this->whereDomainIs($qb, $url->domain());
$result = (int) $qb->getQuery()->getSingleScalarResult();
return $result > 0;
}
private function whereDomainIs(QueryBuilder $qb, ?string $domain): void
{
if ($domain !== null) {
$qb->join('s.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':authority'))
@ -193,7 +276,5 @@ DQL;
} else {
$qb->andWhere($qb->expr()->isNull('s.domain'));
}
return $qb;
}
}

View file

@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
interface ShortUrlRepositoryInterface extends ObjectRepository
{
@ -27,4 +29,8 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl;
public function importedUrlExists(ImportedShlinkUrl $url): bool;
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
class ShortCodeHelper implements ShortCodeHelperInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool
{
$shortCode = $shortUrlToBeCreated->getShortCode();
$domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domainAuthority);
if (! $otherShortUrlsExist) {
return true;
}
if ($hasCustomSlug) {
return false;
}
$shortUrlToBeCreated->regenerateShortCode();
return $this->ensureShortCodeUniqueness($shortUrlToBeCreated, $hasCustomSlug);
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
interface ShortCodeHelperInterface
{
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool;
}

View file

@ -71,7 +71,7 @@ class ShortUrlService implements ShortUrlServiceInterface
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
{
if ($shortUrlEdit->hasLongUrl()) {
$this->urlValidator->validateUrl($shortUrlEdit->longUrl());
$this->urlValidator->validateUrl($shortUrlEdit->longUrl(), $shortUrlEdit->doValidateUrl());
}
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);

View file

@ -5,34 +5,36 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Throwable;
use function array_reduce;
class UrlShortener implements UrlShortenerInterface
{
use TagManagerTrait;
private EntityManagerInterface $em;
private UrlValidatorInterface $urlValidator;
private DomainResolverInterface $domainResolver;
private ShortUrlRelationResolverInterface $relationResolver;
private ShortCodeHelperInterface $shortCodeHelper;
public function __construct(
UrlValidatorInterface $urlValidator,
EntityManagerInterface $em,
DomainResolverInterface $domainResolver
ShortUrlRelationResolverInterface $relationResolver,
ShortCodeHelperInterface $shortCodeHelper
) {
$this->urlValidator = $urlValidator;
$this->em = $em;
$this->domainResolver = $domainResolver;
$this->relationResolver = $relationResolver;
$this->shortCodeHelper = $shortCodeHelper;
}
/**
@ -41,7 +43,7 @@ class UrlShortener implements UrlShortenerInterface
* @throws InvalidUrlException
* @throws Throwable
*/
public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl
public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl
{
// First, check if a short URL exists for all provided params
$existingShortUrl = $this->findExistingShortUrlIfExists($url, $tags, $meta);
@ -49,26 +51,17 @@ class UrlShortener implements UrlShortenerInterface
return $existingShortUrl;
}
$this->urlValidator->validateUrl($url);
$this->em->beginTransaction();
$shortUrl = new ShortUrl($url, $meta, $this->domainResolver);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
$this->urlValidator->validateUrl($url, $meta->doValidateUrl());
return $this->em->transactional(function () use ($url, $tags, $meta) {
$shortUrl = new ShortUrl($url, $meta, $this->relationResolver);
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
try {
$this->verifyShortCodeUniqueness($meta, $shortUrl);
$this->em->persist($shortUrl);
$this->em->flush();
$this->em->commit();
} catch (Throwable $e) {
if ($this->em->getConnection()->isTransactionActive()) {
$this->em->rollback();
$this->em->close();
}
throw $e;
}
return $shortUrl;
return $shortUrl;
});
}
private function findExistingShortUrlIfExists(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
@ -77,42 +70,23 @@ class UrlShortener implements UrlShortenerInterface
return null;
}
$criteria = ['longUrl' => $url];
if ($meta->hasCustomSlug()) {
$criteria['shortCode'] = $meta->getCustomSlug();
}
/** @var ShortUrl[] $shortUrls */
$shortUrls = $this->em->getRepository(ShortUrl::class)->findBy($criteria);
if (empty($shortUrls)) {
return null;
}
// Iterate short URLs until one that matches is found, or return null otherwise
return array_reduce($shortUrls, function (?ShortUrl $found, ShortUrl $shortUrl) use ($tags, $meta) {
if ($found !== null) {
return $found;
}
return $shortUrl->matchesCriteria($meta, $tags) ? $shortUrl : null;
});
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
return $repo->findOneMatching($url, $tags, $meta);
}
private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void
{
$shortCode = $shortUrlToBeCreated->getShortCode();
$domain = $meta->getDomain();
$couldBeMadeUnique = $this->shortCodeHelper->ensureShortCodeUniqueness(
$shortUrlToBeCreated,
$meta->hasCustomSlug(),
);
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$otherShortUrlsExist = $repo->shortCodeIsInUse($shortCode, $domain);
if (! $couldBeMadeUnique) {
$domain = $shortUrlToBeCreated->getDomain();
$domainAuthority = $domain !== null ? $domain->getAuthority() : null;
if ($otherShortUrlsExist && $meta->hasCustomSlug()) {
throw NonUniqueSlugException::fromSlug($shortCode, $domain);
}
if ($otherShortUrlsExist) {
$shortUrlToBeCreated->regenerateShortCode();
$this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated);
throw NonUniqueSlugException::fromSlug($shortUrlToBeCreated->getShortCode(), $domainAuthority);
}
}
}

View file

@ -16,5 +16,5 @@ interface UrlShortenerInterface
* @throws NonUniqueSlugException
* @throws InvalidUrlException
*/
public function urlToShortCode(string $url, array $tags, ShortUrlMeta $meta): ShortUrl;
public function shorten(string $url, array $tags, ShortUrlMeta $meta): ShortUrl;
}

View file

@ -2,12 +2,13 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Domain\Resolver;
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class PersistenceDomainResolver implements DomainResolverInterface
class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{
private EntityManagerInterface $em;
@ -26,4 +27,15 @@ class PersistenceDomainResolver implements DomainResolverInterface
$existingDomain = $this->em->getRepository(Domain::class)->findOneBy(['authority' => $domain]);
return $existingDomain ?? new Domain($domain);
}
public function resolveApiKey(?string $key): ?ApiKey
{
if ($key === null) {
return null;
}
/** @var ApiKey|null $existingApiKey */
$existingApiKey = $this->em->getRepository(ApiKey::class)->findOneBy(['key' => $key]);
return $existingApiKey;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ShortUrlRelationResolverInterface
{
public function resolveDomain(?string $domain): ?Domain;
public function resolveApiKey(?string $key): ?ApiKey;
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ShortUrl\Resolver;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class SimpleShortUrlRelationResolver implements ShortUrlRelationResolverInterface
{
public function resolveDomain(?string $domain): ?Domain
{
return $domain !== null ? new Domain($domain) : null;
}
public function resolveApiKey(?string $key): ?ApiKey
{
return null;
}
}

View file

@ -58,6 +58,7 @@ class TagService implements TagServiceInterface
/**
* Provided a list of tag names, creates all that do not exist yet
*
* @deprecated
* @param string[] $tagNames
* @return Collection|Tag[]
*/

View file

@ -28,6 +28,7 @@ interface TagServiceInterface
public function deleteTags(array $tagNames): void;
/**
* @deprecated
* @param string[] $tagNames
* @return Collection|Tag[]
*/

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Doctrine\ORM\EntityManagerInterface;
use Throwable;
/**
* Inspired by ocramius/doctrine-batch-utils https://github.com/Ocramius/DoctrineBatchUtils
*/
class DoctrineBatchHelper implements DoctrineBatchHelperInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* @throws Throwable
*/
public function wrapIterable(iterable $resultSet, int $batchSize): iterable
{
$iteration = 0;
$this->em->beginTransaction();
try {
foreach ($resultSet as $key => $value) {
$iteration++;
yield $key => $value;
$this->flushAndClearBatch($iteration, $batchSize);
}
} catch (Throwable $e) {
$this->em->rollback();
throw $e;
}
$this->flushAndClearEntityManager();
$this->em->commit();
}
private function flushAndClearBatch(int $iteration, int $batchSize): void
{
if ($iteration % $batchSize) {
return;
}
$this->flushAndClearEntityManager();
}
private function flushAndClearEntityManager(): void
{
$this->em->flush();
$this->em->clear();
}
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
interface DoctrineBatchHelperInterface
{
public function wrapIterable(iterable $resultSet, int $batchSize): iterable;
}

View file

@ -27,10 +27,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
/**
* @throws InvalidUrlException
*/
public function validateUrl(string $url): void
public function validateUrl(string $url, ?bool $doValidate): void
{
// If the URL validation is not enabled, skip check
if (! $this->options->isUrlValidationEnabled()) {
// If the URL validation is not enabled or it was explicitly set to not validate, skip check
$doValidate = $doValidate ?? $this->options->isUrlValidationEnabled();
if (! $doValidate) {
return;
}

View file

@ -11,5 +11,5 @@ interface UrlValidatorInterface
/**
* @throws InvalidUrlException
*/
public function validateUrl(string $url): void;
public function validateUrl(string $url, ?bool $doValidate): void;
}

View file

@ -27,6 +27,8 @@ class ShortUrlMetaInputFilter extends InputFilter
public const DOMAIN = 'domain';
public const SHORT_CODE_LENGTH = 'shortCodeLength';
public const LONG_URL = 'longUrl';
public const VALIDATE_URL = 'validateUrl';
public const API_KEY = 'apiKey';
public function __construct(array $data)
{
@ -64,9 +66,13 @@ class ShortUrlMetaInputFilter extends InputFilter
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
$this->add($this->createInput(self::VALIDATE_URL, false));
$domain = $this->createInput(self::DOMAIN, false);
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
$this->add($domain);
$this->add($this->createInput(self::API_KEY, false));
}
private function createPositiveNumberInput(string $name, int $min = 1): Input

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Domain\Repository;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
class DomainRepositoryTest extends DatabaseTestCase
{
protected const ENTITIES_TO_EMPTY = [Domain::class];
private DomainRepository $repo;
protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(Domain::class);
}
/** @test */
public function findDomainsReturnsExpectedResult(): void
{
$fooDomain = new Domain('foo.com');
$barDomain = new Domain('bar.com');
$bazDomain = new Domain('baz.com');
$this->getEntityManager()->persist($fooDomain);
$this->getEntityManager()->persist($barDomain);
$this->getEntityManager()->persist($bazDomain);
$this->getEntityManager()->flush();
self::assertEquals([$barDomain, $bazDomain, $fooDomain], $this->repo->findDomainsWithout());
self::assertEquals([$barDomain, $bazDomain], $this->repo->findDomainsWithout('foo.com'));
self::assertEquals([$bazDomain, $fooDomain], $this->repo->findDomainsWithout('bar.com'));
self::assertEquals([$barDomain, $fooDomain], $this->repo->findDomainsWithout('baz.com'));
}
}

View file

@ -16,12 +16,16 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function count;
class ShortUrlRepositoryTest extends DatabaseTestCase
{
use TagManagerTrait;
protected const ENTITIES_TO_EMPTY = [
Tag::class,
Visit::class,
@ -54,25 +58,25 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode()));
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback(
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode()));
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
$withDomainDuplicatingRegular->getShortCode(),
));
$this->assertSame($withDomain, $this->repo->findOneWithDomainFallback(
self::assertSame($withDomain, $this->repo->findOneWithDomainFallback(
$withDomain->getShortCode(),
'example.com',
));
$this->assertSame(
self::assertSame(
$withDomainDuplicatingRegular,
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
);
$this->assertSame(
self::assertSame(
$regularOne,
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
);
$this->assertNull($this->repo->findOneWithDomainFallback('invalid'));
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode()));
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com'));
self::assertNull($this->repo->findOneWithDomainFallback('invalid'));
self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode()));
self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com'));
}
/** @test */
@ -84,7 +88,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
}
$this->getEntityManager()->flush();
$this->assertEquals($count, $this->repo->countList());
self::assertEquals($count, $this->repo->countList());
}
/** @test */
@ -113,37 +117,37 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, 'foo', ['bar']);
$this->assertCount(1, $result);
$this->assertEquals(1, $this->repo->countList('foo', ['bar']));
$this->assertSame($foo, $result[0]);
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList('foo', ['bar']));
self::assertSame($foo, $result[0]);
$result = $this->repo->findList();
$this->assertCount(3, $result);
self::assertCount(3, $result);
$result = $this->repo->findList(2);
$this->assertCount(2, $result);
self::assertCount(2, $result);
$result = $this->repo->findList(2, 1);
$this->assertCount(2, $result);
self::assertCount(2, $result);
$this->assertCount(1, $this->repo->findList(2, 2));
self::assertCount(1, $this->repo->findList(2, 2));
$result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
'orderBy' => ['visits' => 'DESC'],
]));
$this->assertCount(3, $result);
$this->assertSame($bar, $result[0]);
self::assertCount(3, $result);
self::assertSame($bar, $result[0]);
$result = $this->repo->findList(null, null, null, [], null, new DateRange(null, Chronos::now()->subDays(2)));
$this->assertCount(1, $result);
$this->assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2))));
$this->assertSame($foo2, $result[0]);
self::assertCount(1, $result);
self::assertEquals(1, $this->repo->countList(null, [], new DateRange(null, Chronos::now()->subDays(2))));
self::assertSame($foo2, $result[0]);
$this->assertCount(
self::assertCount(
2,
$this->repo->findList(null, null, null, [], null, new DateRange(Chronos::now()->subDays(2))),
);
$this->assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2))));
self::assertEquals(2, $this->repo->countList(null, [], new DateRange(Chronos::now()->subDays(2))));
}
/** @test */
@ -160,11 +164,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
'orderBy' => ['longUrl' => 'ASC'],
]));
$this->assertCount(count($urls), $result);
$this->assertEquals('a', $result[0]->getLongUrl());
$this->assertEquals('b', $result[1]->getLongUrl());
$this->assertEquals('c', $result[2]->getLongUrl());
$this->assertEquals('z', $result[3]->getLongUrl());
self::assertCount(count($urls), $result);
self::assertEquals('a', $result[0]->getLongUrl());
self::assertEquals('b', $result[1]->getLongUrl());
self::assertEquals('c', $result[2]->getLongUrl());
self::assertEquals('z', $result[3]->getLongUrl());
}
/** @test */
@ -181,12 +185,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertTrue($this->repo->shortCodeIsInUse('my-cool-slug'));
$this->assertFalse($this->repo->shortCodeIsInUse('my-cool-slug', 'doma.in'));
$this->assertFalse($this->repo->shortCodeIsInUse('slug-not-in-use'));
$this->assertFalse($this->repo->shortCodeIsInUse('another-slug'));
$this->assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com'));
$this->assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in'));
self::assertTrue($this->repo->shortCodeIsInUse('my-cool-slug'));
self::assertFalse($this->repo->shortCodeIsInUse('my-cool-slug', 'doma.in'));
self::assertFalse($this->repo->shortCodeIsInUse('slug-not-in-use'));
self::assertFalse($this->repo->shortCodeIsInUse('another-slug'));
self::assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com'));
self::assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in'));
}
/** @test */
@ -203,11 +207,140 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertNotNull($this->repo->findOne('my-cool-slug'));
$this->assertNull($this->repo->findOne('my-cool-slug', 'doma.in'));
$this->assertNull($this->repo->findOne('slug-not-in-use'));
$this->assertNull($this->repo->findOne('another-slug'));
$this->assertNull($this->repo->findOne('another-slug', 'example.com'));
$this->assertNotNull($this->repo->findOne('another-slug', 'doma.in'));
self::assertNotNull($this->repo->findOne('my-cool-slug'));
self::assertNull($this->repo->findOne('my-cool-slug', 'doma.in'));
self::assertNull($this->repo->findOne('slug-not-in-use'));
self::assertNull($this->repo->findOne('another-slug'));
self::assertNull($this->repo->findOne('another-slug', 'example.com'));
self::assertNotNull($this->repo->findOne('another-slug', 'doma.in'));
}
/** @test */
public function findOneMatchingReturnsNullForNonExistingShortUrls(): void
{
self::assertNull($this->repo->findOneMatching('', [], ShortUrlMeta::createEmpty()));
self::assertNull($this->repo->findOneMatching('foobar', [], ShortUrlMeta::createEmpty()));
self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::createEmpty()));
self::assertNull($this->repo->findOneMatching('foobar', ['foo', 'bar'], ShortUrlMeta::fromRawData([
'validSince' => Chronos::parse('2020-03-05 20:18:30'),
'customSlug' => 'this_slug_does_not_exist',
])));
}
/** @test */
public function findOneMatchingAppliesProperConditions(): void
{
$start = Chronos::parse('2020-03-05 20:18:30');
$end = Chronos::parse('2021-03-05 20:18:30');
$shortUrl = new ShortUrl('foo', ShortUrlMeta::fromRawData(['validSince' => $start]));
$shortUrl->setTags($this->tagNamesToEntities($this->getEntityManager(), ['foo', 'bar']));
$this->getEntityManager()->persist($shortUrl);
$shortUrl2 = new ShortUrl('bar', ShortUrlMeta::fromRawData(['validUntil' => $end]));
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = new ShortUrl('baz', ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end]));
$this->getEntityManager()->persist($shortUrl3);
$shortUrl4 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'validUntil' => $end]));
$this->getEntityManager()->persist($shortUrl4);
$shortUrl5 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['maxVisits' => 3]));
$this->getEntityManager()->persist($shortUrl5);
$shortUrl6 = new ShortUrl('foo', ShortUrlMeta::fromRawData(['domain' => 'doma.in']));
$this->getEntityManager()->persist($shortUrl6);
$this->getEntityManager()->flush();
self::assertSame(
$shortUrl,
$this->repo->findOneMatching('foo', ['foo', 'bar'], ShortUrlMeta::fromRawData(['validSince' => $start])),
);
self::assertSame(
$shortUrl2,
$this->repo->findOneMatching('bar', [], ShortUrlMeta::fromRawData(['validUntil' => $end])),
);
self::assertSame(
$shortUrl3,
$this->repo->findOneMatching('baz', [], ShortUrlMeta::fromRawData([
'validSince' => $start,
'validUntil' => $end,
])),
);
self::assertSame(
$shortUrl4,
$this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData([
'customSlug' => 'custom',
'validUntil' => $end,
])),
);
self::assertSame(
$shortUrl5,
$this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['maxVisits' => 3])),
);
self::assertSame(
$shortUrl6,
$this->repo->findOneMatching('foo', [], ShortUrlMeta::fromRawData(['domain' => 'doma.in'])),
);
}
/** @test */
public function findOneMatchingReturnsOldestOneWhenThereAreMultipleMatches(): void
{
$start = Chronos::parse('2020-03-05 20:18:30');
$meta = ['validSince' => $start, 'maxVisits' => 50];
$tags = ['foo', 'bar'];
$tagEntities = $this->tagNamesToEntities($this->getEntityManager(), $tags);
$shortUrl1 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta));
$shortUrl1->setTags($tagEntities);
$this->getEntityManager()->persist($shortUrl1);
$shortUrl2 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta));
$shortUrl2->setTags($tagEntities);
$this->getEntityManager()->persist($shortUrl2);
$shortUrl3 = new ShortUrl('foo', ShortUrlMeta::fromRawData($meta));
$shortUrl3->setTags($tagEntities);
$this->getEntityManager()->persist($shortUrl3);
$this->getEntityManager()->flush();
self::assertSame(
$shortUrl1,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
);
self::assertNotSame(
$shortUrl2,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
);
self::assertNotSame(
$shortUrl3,
$this->repo->findOneMatching('foo', $tags, ShortUrlMeta::fromRawData($meta)),
);
}
/** @test */
public function importedShortUrlsAreSearchedAsExpected(): void
{
$buildImported = static fn (string $shortCode, ?String $domain = null) =>
new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode);
$shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true);
$this->getEntityManager()->persist($shortUrlWithoutDomain);
$shortUrlWithDomain = ShortUrl::fromImport($buildImported('another-slug', 'doma.in'), true);
$this->getEntityManager()->persist($shortUrlWithDomain);
$this->getEntityManager()->flush();
self::assertTrue($this->repo->importedUrlExists($buildImported('my-cool-slug')));
self::assertTrue($this->repo->importedUrlExists($buildImported('another-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug')));
self::assertFalse($this->repo->importedUrlExists($buildImported('non-existing-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('my-cool-slug', 'doma.in')));
self::assertFalse($this->repo->importedUrlExists($buildImported('another-slug')));
}
}

View file

@ -32,7 +32,7 @@ class TagRepositoryTest extends DatabaseTestCase
/** @test */
public function deleteByNameDoesNothingWhenEmptyListIsProvided(): void
{
$this->assertEquals(0, $this->repo->deleteByName([]));
self::assertEquals(0, $this->repo->deleteByName([]));
}
/** @test */
@ -46,7 +46,7 @@ class TagRepositoryTest extends DatabaseTestCase
}
$this->getEntityManager()->flush();
$this->assertEquals(2, $this->repo->deleteByName($toDelete));
self::assertEquals(2, $this->repo->deleteByName($toDelete));
}
/** @test */
@ -79,20 +79,20 @@ class TagRepositoryTest extends DatabaseTestCase
$result = $this->repo->findTagsWithInfo();
$this->assertCount(4, $result);
$this->assertEquals(
self::assertCount(4, $result);
self::assertEquals(
['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0],
$result[0]->jsonSerialize(),
);
$this->assertEquals(
self::assertEquals(
['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3],
$result[1]->jsonSerialize(),
);
$this->assertEquals(
self::assertEquals(
['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3],
$result[2]->jsonSerialize(),
);
$this->assertEquals(
self::assertEquals(
['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4],
$result[3]->jsonSerialize(),
);

View file

@ -75,9 +75,9 @@ class VisitRepositoryTest extends DatabaseTestCase
// Important! assertCount will not work here, as this iterable object loads data dynamically and the count
// is 0 if not iterated
$this->assertEquals(2, $countIterable($unlocated));
$this->assertEquals(4, $countIterable($withEmptyLocation));
$this->assertEquals(6, $countIterable($all));
self::assertEquals(2, $countIterable($unlocated));
self::assertEquals(4, $countIterable($withEmptyLocation));
self::assertEquals(6, $countIterable($all));
}
public function provideBlockSize(): iterable
@ -90,22 +90,22 @@ class VisitRepositoryTest extends DatabaseTestCase
{
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
$this->assertCount(0, $this->repo->findVisitsByShortCode('invalid'));
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortCode));
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
self::assertCount(0, $this->repo->findVisitsByShortCode('invalid'));
self::assertCount(6, $this->repo->findVisitsByShortCode($shortCode));
self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain));
self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
self::assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange(
self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2));
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4));
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2));
self::assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2));
self::assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4));
self::assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2));
}
/** @test */
@ -113,17 +113,17 @@ class VisitRepositoryTest extends DatabaseTestCase
{
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortCode));
$this->assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain));
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
self::assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
self::assertEquals(6, $this->repo->countVisitsByShortCode($shortCode));
self::assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain));
self::assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
self::assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange(
self::assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange(
Chronos::parse('2016-01-03'),
)));
}
@ -147,13 +147,13 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertCount(0, $this->repo->findVisitsByTag('invalid'));
$this->assertCount(18, $this->repo->findVisitsByTag((string) $foo));
$this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
self::assertCount(0, $this->repo->findVisitsByTag('invalid'));
self::assertCount(18, $this->repo->findVisitsByTag((string) $foo));
self::assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
self::assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-03'),
)));
}
@ -174,13 +174,13 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
$this->assertEquals(0, $this->repo->countVisitsByTag('invalid'));
$this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
$this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
self::assertEquals(0, $this->repo->countVisitsByTag('invalid'));
self::assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
self::assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
self::assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-03'),
)));
}

View file

@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Action;
use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\PixelResponse;
@ -19,6 +20,8 @@ use Shlinkio\Shlink\Core\Service\VisitsTracker;
class PixelActionTest extends TestCase
{
use ProphecyTrait;
private PixelAction $action;
private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker;
@ -47,8 +50,8 @@ class PixelActionTest extends TestCase
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$this->assertInstanceOf(PixelResponse::class, $response);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('image/gif', $response->getHeaderLine('content-type'));
self::assertInstanceOf(PixelResponse::class, $response);
self::assertEquals(200, $response->getStatusCode());
self::assertEquals('image/gif', $response->getHeaderLine('content-type'));
}
}

View file

@ -9,6 +9,7 @@ use Laminas\Diactoros\ServerRequest;
use Mezzio\Router\RouterInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
@ -20,6 +21,8 @@ use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
class QrCodeActionTest extends TestCase
{
use ProphecyTrait;
private QrCodeAction $action;
private ObjectProphecy $urlResolver;
@ -77,8 +80,34 @@ class QrCodeActionTest extends TestCase
$delegate->reveal(),
);
$this->assertInstanceOf(QrCodeResponse::class, $resp);
$this->assertEquals(200, $resp->getStatusCode());
self::assertInstanceOf(QrCodeResponse::class, $resp);
self::assertEquals(200, $resp->getStatusCode());
$delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0);
}
/**
* @test
* @dataProvider provideQueries
*/
public function imageIsReturnedWithExpectedContentTypeBasedOnProvidedFormat(
array $query,
string $expectedContentType
): void {
$code = 'abc123';
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn(new ShortUrl(''));
$delegate = $this->prophesize(RequestHandlerInterface::class);
$req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query);
$resp = $this->action->process($req, $delegate->reveal());
self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type'));
}
public function provideQueries(): iterable
{
yield 'no format' => [[], 'image/png'];
yield 'png format' => [['format' => 'png'], 'image/png'];
yield 'svg format' => [['format' => 'svg'], 'image/svg+xml'];
yield 'unsupported format' => [['format' => 'jpg'], 'image/png'];
}
}

View file

@ -10,6 +10,7 @@ use Laminas\Diactoros\ServerRequest;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
@ -24,6 +25,8 @@ use function array_key_exists;
class RedirectActionTest extends TestCase
{
use ProphecyTrait;
private RedirectAction $action;
private ObjectProphecy $urlResolver;
private ObjectProphecy $visitTracker;
@ -60,10 +63,10 @@ class RedirectActionTest extends TestCase
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
$this->assertEquals(302, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Location'));
$this->assertEquals($expectedUrl, $response->getHeaderLine('Location'));
self::assertInstanceOf(Response\RedirectResponse::class, $response);
self::assertEquals(302, $response->getStatusCode());
self::assertTrue($response->hasHeader('Location'));
self::assertEquals($expectedUrl, $response->getHeaderLine('Location'));
$shortCodeToUrl->shouldHaveBeenCalledOnce();
$track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1);
}
@ -135,10 +138,10 @@ class RedirectActionTest extends TestCase
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
$this->assertEquals($expectedStatus, $response->getStatusCode());
$this->assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null);
$this->assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? '');
self::assertInstanceOf(Response\RedirectResponse::class, $response);
self::assertEquals($expectedStatus, $response->getStatusCode());
self::assertEquals($response->hasHeader('Cache-Control'), $expectedCacheControl !== null);
self::assertEquals($response->getHeaderLine('Cache-Control'), $expectedCacheControl ?? '');
}
public function provideRedirectConfigs(): iterable

View file

@ -32,9 +32,9 @@ class BasePathPrefixerTest extends TestCase
'url_shortener' => $urlShortener,
] = ($this->prefixer)($originalConfig);
$this->assertEquals($expectedRoutes, $routes);
$this->assertEquals($expectedMiddlewares, $middlewares);
$this->assertEquals([
self::assertEquals($expectedRoutes, $routes);
self::assertEquals($expectedMiddlewares, $middlewares);
self::assertEquals([
'domain' => [
'hostname' => $expectedHostname,
],

View file

@ -29,7 +29,7 @@ class DeprecatedConfigParserTest extends TestCase
$result = ($this->postProcessor)($config);
$this->assertEquals($config, $result);
self::assertEquals($config, $result);
}
/** @test */
@ -46,7 +46,7 @@ class DeprecatedConfigParserTest extends TestCase
$result = ($this->postProcessor)($config);
$this->assertEquals($config, $result);
self::assertEquals($config, $result);
}
/** @test */
@ -68,7 +68,7 @@ class DeprecatedConfigParserTest extends TestCase
$result = ($this->postProcessor)($config);
$this->assertEquals($expected, $result);
self::assertEquals($expected, $result);
}
/** @test */
@ -89,7 +89,7 @@ class DeprecatedConfigParserTest extends TestCase
$result = ($this->postProcessor)($config);
$this->assertEquals($expected, $result);
self::assertEquals($expected, $result);
}
/** @test */
@ -106,6 +106,6 @@ class DeprecatedConfigParserTest extends TestCase
$result = ($this->postProcessor)($config);
$this->assertEquals($expected, $result);
self::assertEquals($expected, $result);
}
}

View file

@ -67,6 +67,7 @@ class SimplifiedConfigParserTest extends TestCase
'anonymize_remote_addr' => false,
'redirect_status_code' => 301,
'redirect_cache_lifetime' => 90,
'port' => 8888,
];
$expected = [
'app_options' => [
@ -132,6 +133,7 @@ class SimplifiedConfigParserTest extends TestCase
'mezzio-swoole' => [
'swoole-http-server' => [
'port' => 8888,
'options' => [
'task_worker_num' => 50,
],
@ -151,6 +153,6 @@ class SimplifiedConfigParserTest extends TestCase
$result = ($this->postProcessor)(array_merge($config, $simplified));
$this->assertEquals(array_merge($expected, $simplified), $result);
self::assertEquals(array_merge($expected, $simplified), $result);
}
}

View file

@ -21,9 +21,9 @@ class ConfigProviderTest extends TestCase
{
$config = $this->configProvider->__invoke();
$this->assertArrayHasKey('routes', $config);
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey('templates', $config);
$this->assertArrayHasKey('mezzio', $config);
self::assertArrayHasKey('routes', $config);
self::assertArrayHasKey('dependencies', $config);
self::assertArrayHasKey('templates', $config);
self::assertArrayHasKey('mezzio', $config);
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Domain;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface;
use Shlinkio\Shlink\Core\Entity\Domain;
class DomainServiceTest extends TestCase
{
use ProphecyTrait;
private DomainService $domainService;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->domainService = new DomainService($this->em->reveal());
}
/**
* @test
* @dataProvider provideExcludedDomains
*/
public function listDomainsWithoutDelegatesIntoRepository(?string $excludedDomain, array $expectedResult): void
{
$repo = $this->prophesize(DomainRepositoryInterface::class);
$getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$findDomains = $repo->findDomainsWithout($excludedDomain)->willReturn($expectedResult);
$result = $this->domainService->listDomainsWithout($excludedDomain);
self::assertEquals($expectedResult, $result);
$getRepo->shouldHaveBeenCalledOnce();
$findDomains->shouldHaveBeenCalledOnce();
}
public function provideExcludedDomains(): iterable
{
yield 'no excluded domain' => [null, []];
yield 'foo.com excluded domain' => ['foo.com', []];
yield 'bar.com excluded domain' => ['bar.com', [new Domain('bar.com')]];
yield 'baz.com excluded domain' => ['baz.com', [new Domain('foo.com'), new Domain('bar.com')]];
}
}

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Domain\Resolver;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectRepository;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\Domain;
class PersistenceDomainResolverTest extends TestCase
{
private PersistenceDomainResolver $domainResolver;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->domainResolver = new PersistenceDomainResolver($this->em->reveal());
}
/** @test */
public function returnsEmptyWhenNoDomainIsProvided(): void
{
$getRepository = $this->em->getRepository(Domain::class);
$this->assertNull($this->domainResolver->resolveDomain(null));
$getRepository->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideFoundDomains
*/
public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void
{
$repo = $this->prophesize(ObjectRepository::class);
$findDomain = $repo->findOneBy(['authority' => $authority])->willReturn($foundDomain);
$getRepository = $this->em->getRepository(Domain::class)->willReturn($repo->reveal());
$result = $this->domainResolver->resolveDomain($authority);
if ($foundDomain !== null) {
$this->assertSame($result, $foundDomain);
}
$this->assertInstanceOf(Domain::class, $result);
$this->assertEquals($authority, $result->getAuthority());
$findDomain->shouldHaveBeenCalledOnce();
$getRepository->shouldHaveBeenCalledOnce();
}
public function provideFoundDomains(): iterable
{
$authority = 'doma.in';
yield 'without found domain' => [null, $authority];
yield 'with found domain' => [new Domain($authority), $authority];
}
}

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